Merging to latest

Dean Putney преди 9 години
родител
ревизия
d85ca73857
променени са 72 файла, в които са добавени 1550 реда и са изтрити 786 реда
  1. 68 79
      Gemfile
  2. 8 6
      Gemfile.lock
  3. 1 0
      README.md
  4. 12 0
      app/assets/javascripts/application.js.coffee
  5. 0 226
      app/assets/javascripts/application.js.coffee.erb
  6. 30 0
      app/assets/javascripts/components/core.js.coffee
  7. 14 0
      app/assets/javascripts/components/json-editor.js.coffee.erb
  8. 29 0
      app/assets/javascripts/components/search.js.coffee
  9. 14 0
      app/assets/javascripts/components/utils.js.coffee
  10. 0 0
      app/assets/javascripts/components/worker-checker.js.coffee
  11. 2 0
      app/assets/javascripts/diagram.js.coffee
  12. 2 0
      app/assets/javascripts/graphing.js.coffee
  13. 41 0
      app/assets/javascripts/map_marker.js.coffee
  14. 126 0
      app/assets/javascripts/pages/agent-edit-page.js.coffee
  15. 35 0
      app/assets/javascripts/pages/agent-show-page.js.coffee
  16. 2 0
      app/assets/javascripts/user_credentials.js.coffee
  17. 43 1
      app/assets/stylesheets/application.css.scss.erb
  18. 5 5
      app/assets/stylesheets/tables.css.scss
  19. 9 1
      app/concerns/twitter_concern.rb
  20. 2 4
      app/concerns/weibo_concern.rb
  21. 1 1
      app/controllers/agents_controller.rb
  22. 3 1
      app/controllers/user_credentials_controller.rb
  23. 2 0
      app/helpers/application_helper.rb
  24. 5 5
      app/helpers/dot_helper.rb
  25. 0 5
      app/helpers/service_helper.rb
  26. 19 2
      app/models/agent.rb
  27. 0 1
      app/models/agents/adioso_agent.rb
  28. 9 9
      app/models/agents/ftpsite_agent.rb
  29. 3 0
      app/models/agents/google_calendar_publish_agent.rb
  30. 5 4
      app/models/agents/growl_agent.rb
  31. 7 1
      app/models/agents/hipchat_agent.rb
  32. 212 208
      app/models/agents/human_task_agent.rb
  33. 3 0
      app/models/agents/jabber_agent.rb
  34. 3 1
      app/models/agents/mqtt_agent.rb
  35. 6 2
      app/models/agents/slack_agent.rb
  36. 13 8
      app/models/agents/twilio_agent.rb
  37. 1 2
      app/models/agents/twitter_publish_agent.rb
  38. 1 0
      app/models/agents/twitter_stream_agent.rb
  39. 1 2
      app/models/agents/twitter_user_agent.rb
  40. 4 2
      app/models/agents/user_location_agent.rb
  41. 4 1
      app/models/agents/weather_agent.rb
  42. 2 3
      app/models/agents/weibo_publish_agent.rb
  43. 1 1
      app/models/agents/weibo_user_agent.rb
  44. 43 1
      app/models/event.rb
  45. 74 70
      app/views/agents/_form.html.erb
  46. 2 2
      app/views/agents/_oauth_dropdown.html.erb
  47. 6 6
      app/views/agents/_table.html.erb
  48. 8 6
      app/views/agents/agent_views/user_location_agent/_show.html.erb
  49. 3 3
      app/views/devise/registrations/new.html.erb
  50. 6 3
      app/views/events/show.html.erb
  51. 5 5
      app/views/layouts/_navigation.html.erb
  52. 11 12
      app/views/layouts/application.html.erb
  53. 6 6
      app/views/services/index.html.erb
  54. 0 61
      app/views/shared/_map_marker.html.erb
  55. 3 2
      app/views/user_credentials/index.html.erb
  56. 29 19
      bin/setup_heroku
  57. 4 0
      bin/threaded.rb
  58. 1 1
      config/environments/production.rb
  59. 1 1
      config/initializers/aws.rb
  60. 41 4
      config/initializers/omniauth.rb
  61. 30 0
      docker/Dockerfile
  62. 0 0
      docker/Dockerfile.rbenv
  63. 2 0
      docker/Makefile
  64. 137 0
      docker/README.md
  65. 111 0
      docker/scripts/init
  66. 39 0
      docker/scripts/setup
  67. 1 1
      lib/huginn_scheduler.rb
  68. 110 0
      lib/location.rb
  69. 8 1
      lib/twitter_stream.rb
  70. 68 0
      spec/lib/location_spec.rb
  71. 51 0
      spec/models/event_spec.rb
  72. 2 1
      vendor/assets/javascripts/jquery.json-editor.js

+ 68 - 79
Gemfile

@@ -1,5 +1,33 @@
1 1
 source 'https://rubygems.org'
2 2
 
3
+# Optional libraries.  To conserve RAM, comment out any that you don't need,
4
+# then run `bundle` and commit the updated Gemfile and Gemfile.lock.
5
+gem 'twilio-ruby', '~> 3.11.5'    # TwilioAgent
6
+gem 'ruby-growl', '~> 4.1.0'      # GrowlAgent
7
+gem 'net-ftp-list', '~> 3.2.8'    # FtpsiteAgent
8
+gem 'wunderground', '~> 1.2.0'    # WeatherAgent
9
+gem 'forecast_io', '~> 2.0.0'     # WeatherAgent
10
+gem 'rturk', '~> 2.12.1'          # HumanTaskAgent
11
+gem 'weibo_2', '~> 0.1.4'         # Weibo Agents
12
+gem 'hipchat', '~> 1.2.0'         # HipchatAgent
13
+gem 'xmpp4r',  '~> 0.5.6'         # JabberAgent
14
+gem "google-api-client"           # GoogleCalendarPublishAgent
15
+gem 'mqtt'                        # MQTTAgent
16
+gem 'slack-notifier', '~> 0.5.0'  # SlackAgent
17
+
18
+# Twitter Agents
19
+gem 'twitter', '~> 5.8.0' # Must to be loaded before cantino-twitter-stream.
20
+gem 'cantino-twitter-stream', github: 'cantino/twitter-stream', branch: 'master'
21
+gem 'omniauth-twitter'
22
+
23
+# Tumblr Agents
24
+gem 'tumblr_client'
25
+gem 'omniauth-tumblr'
26
+
27
+# Optional Services.
28
+gem 'omniauth-37signals'          # BasecampAgent
29
+# gem 'omniauth-github'
30
+
3 31
 # Bundler <1.5 does not recognize :x64_mingw as a valid platform name.
4 32
 # Unfortunately, it can't self-update because it errors when encountering :x64_mingw.
5 33
 unless Gem::Version.new(Bundler::VERSION) >= Gem::Version.new('1.5.0')
@@ -7,111 +35,66 @@ unless Gem::Version.new(Bundler::VERSION) >= Gem::Version.new('1.5.0')
7 35
   exit 1
8 36
 end
9 37
 
10
-gem 'bundler', '>= 1.5.0'
11
-
12
-gem 'protected_attributes', '~>1.0.8'
13
-
14
-gem 'rails' , '4.1.5'
15
-
16
-case RUBY_PLATFORM
17
-when /freebsd|netbsd|openbsd/
18
-  # ffi (required by typhoeus via ethon) merged fixes for bugs fatal
19
-  # on these platforms after 1.9.3; no following release as yet.
20
-  gem 'ffi', github: 'ffi/ffi', branch: 'master'
38
+gem 'protected_attributes', '~>1.0.8' # This must be loaded before some other gems, like delayed_job.
21 39
 
22
-  # tzinfo 1.2.0 has added support for reading zoneinfo on these
23
-  # platforms.
24
-  gem 'tzinfo', '>= 1.2.0'
25
-when /solaris/
26
-  # ditto
27
-  gem 'tzinfo', '>= 1.2.0'
28
-end
29
-
30
-# Windows does not have zoneinfo files, so bundle the tzinfo-data gem.
31
-gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw]
32
-
33
-gem 'mysql2', '~> 0.3.16'
34
-gem 'devise', '~> 3.2.4'
35
-gem 'kaminari', '~> 0.16.1'
40
+gem 'ace-rails-ap', '~> 2.0.1'
36 41
 gem 'bootstrap-kaminari-views', '~> 0.0.3'
37
-gem 'rufus-scheduler', '~> 3.0.8', require: false
38
-gem 'json', '~> 1.8.1'
39
-gem 'jsonpath', '~> 0.5.6'
40
-gem 'twilio-ruby', '~> 3.11.5'
41
-gem 'ruby-growl', '~> 4.1.0'
42
-gem 'liquid', '~> 2.6.1'
43
-
42
+gem 'bundler', '>= 1.5.0'
43
+gem 'coffee-rails', '~> 4.0.0'
44
+gem 'daemons', '~> 1.1.9'
44 45
 gem 'delayed_job', '~> 4.0.0'
45 46
 gem 'delayed_job_active_record', '~> 4.0.0'
46
-gem 'daemons', '~> 1.1.9'
47
-
47
+gem 'devise', '~> 3.2.4'
48
+gem 'em-http-request', '~> 1.1.2'
49
+gem 'faraday', '~> 0.9.0'
50
+gem 'faraday_middleware'
51
+gem 'feed-normalizer'
52
+gem 'font-awesome-sass'
48 53
 gem 'foreman', '~> 0.63.0'
49
-
50
-gem 'sass-rails',   '~> 4.0.0'
51
-gem 'coffee-rails', '~> 4.0.0'
52
-gem 'uglifier', '>= 1.3.0'
53
-gem 'select2-rails', '~> 3.5.4'
54
-gem 'jquery-rails', '~> 3.1.0'
55
-gem 'ace-rails-ap', '~> 2.0.1'
56
-gem 'spectrum-rails'
57
-
58
-
59 54
 # geokit-rails doesn't work with geokit 1.8.X but it specifies ~> 1.5
60 55
 # in its own Gemfile.
61 56
 gem 'geokit', '~> 1.8.4'
62 57
 gem 'geokit-rails', '~> 2.0.1'
63
-
58
+gem 'httparty', '~> 0.13'
59
+gem 'jquery-rails', '~> 3.1.0'
60
+gem 'json', '~> 1.8.1'
61
+gem 'jsonpath', '~> 0.5.6'
62
+gem 'kaminari', '~> 0.16.1'
64 63
 gem 'kramdown', '~> 1.3.3'
65
-gem 'faraday', '~> 0.9.0'
66
-gem 'faraday_middleware'
67
-gem 'typhoeus', '~> 0.6.3'
64
+gem 'liquid', '~> 2.6.1'
65
+gem 'mysql2', '~> 0.3.16'
66
+gem 'multi_xml'
68 67
 gem 'nokogiri', '~> 1.6.1'
69
-gem 'net-ftp-list', '~> 3.2.8'
70
-
71
-gem 'wunderground', '~> 1.2.0'
72
-gem 'forecast_io', '~> 2.0.0'
73
-gem 'rturk', '~> 2.12.1'
74
-
75
-gem "google-api-client"
76
-
77
-gem 'twitter', '~> 5.8.0'
78
-gem 'cantino-twitter-stream', github: 'cantino/twitter-stream', branch: 'master'
79
-gem 'em-http-request', '~> 1.1.2'
80
-gem 'weibo_2', '~> 0.1.4'
81
-gem 'hipchat', '~> 1.2.0'
82
-gem 'xmpp4r',  '~> 0.5.6'
83
-gem 'feed-normalizer'
84
-gem 'slack-notifier', '~> 0.5.0'
85
-gem 'therubyracer', '~> 0.12.1'
86
-gem 'mqtt'
87
-gem 'tumblr_client'
88
-
89 68
 gem 'omniauth'
90
-gem 'omniauth-twitter'
91
-gem 'omniauth-37signals'
92
-gem 'omniauth-github'
93
-gem 'omniauth-tumblr'
69
+gem 'rails' , '4.1.5'
70
+gem 'rufus-scheduler', '~> 3.0.8', require: false
71
+gem 'sass-rails',   '~> 4.0.0'
72
+gem 'select2-rails', '~> 3.5.4'
73
+gem 'spectrum-rails'
74
+gem 'therubyracer', '~> 0.12.1'
75
+gem 'typhoeus', '~> 0.6.3'
76
+gem 'uglifier', '>= 1.3.0'
94 77
 
95 78
 group :development do
96
-  gem 'binding_of_caller'
97 79
   gem 'better_errors', '~> 1.1'
80
+  gem 'binding_of_caller'
98 81
   gem 'quiet_assets'
99 82
 end
100 83
 
101 84
 group :development, :test do
102
-  gem 'vcr'
85
+  gem 'coveralls', require: false
86
+  gem 'delorean'
103 87
   gem 'dotenv-rails'
104 88
   gem 'pry'
105
-  gem 'rspec-rails', '~> 2.99'
89
+  gem 'rr'
106 90
   gem 'rspec', '~> 2.99'
107 91
   gem 'rspec-collection_matchers'
92
+  gem 'rspec-rails', '~> 2.99'
108 93
   gem 'shoulda-matchers'
109
-  gem 'rr'
110
-  gem 'delorean'
111
-  gem 'webmock', '~> 1.17.4', require: false
112
-  gem 'coveralls', require: false
113 94
   gem 'spring'
114 95
   gem 'spring-commands-rspec'
96
+  gem 'vcr'
97
+  gem 'webmock', '~> 1.17.4', require: false
115 98
 end
116 99
 
117 100
 group :production do
@@ -119,6 +102,12 @@ group :production do
119 102
   gem 'rack'
120 103
 end
121 104
 
105
+# Platform requirements.
106
+gem 'ffi', '>= 1.9.4'		# required by typhoeus; 1.9.4 has fixes for *BSD.
107
+gem 'tzinfo', '>= 1.2.0'	# required by rails; 1.2.0 has support for *BSD and Solaris.
108
+# Windows does not have zoneinfo files, so bundle the tzinfo-data gem.
109
+gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw]
110
+
122 111
 # This hack needs some explanation.  When on Heroku, use the pg, unicorn, and rails12factor gems.
123 112
 # When not on Heroku, we still want our Gemfile.lock to include these gems, so we scope them to
124 113
 # an unsupported platform.

+ 8 - 6
Gemfile.lock

@@ -119,7 +119,9 @@ GEM
119 119
     feed-normalizer (1.5.2)
120 120
       hpricot (>= 0.6)
121 121
       simple-rss (>= 1.1)
122
-    ffi (1.9.3)
122
+    ffi (1.9.5)
123
+    font-awesome-sass (4.2.0)
124
+      sass (~> 3.2)
123 125
     forecast_io (2.0.0)
124 126
       faraday
125 127
       hashie
@@ -204,9 +206,6 @@ GEM
204 206
     omniauth-37signals (1.0.5)
205 207
       omniauth (~> 1.0)
206 208
       omniauth-oauth2 (~> 1.0)
207
-    omniauth-github (1.1.2)
208
-      omniauth (~> 1.0)
209
-      omniauth-oauth2 (~> 1.1)
210 209
     omniauth-oauth (1.0.1)
211 210
       oauth
212 211
       omniauth (~> 1.0)
@@ -419,12 +418,15 @@ DEPENDENCIES
419 418
   faraday (~> 0.9.0)
420 419
   faraday_middleware
421 420
   feed-normalizer
421
+  ffi (>= 1.9.4)
422
+  font-awesome-sass
422 423
   forecast_io (~> 2.0.0)
423 424
   foreman (~> 0.63.0)
424 425
   geokit (~> 1.8.4)
425 426
   geokit-rails (~> 2.0.1)
426 427
   google-api-client
427 428
   hipchat (~> 1.2.0)
429
+  httparty (~> 0.13)
428 430
   jquery-rails (~> 3.1.0)
429 431
   json (~> 1.8.1)
430 432
   jsonpath (~> 0.5.6)
@@ -432,13 +434,12 @@ DEPENDENCIES
432 434
   kramdown (~> 1.3.3)
433 435
   liquid (~> 2.6.1)
434 436
   mqtt
437
+  multi_xml
435 438
   mysql2 (~> 0.3.16)
436 439
   net-ftp-list (~> 3.2.8)
437 440
   nokogiri (~> 1.6.1)
438 441
   omniauth
439 442
   omniauth-37signals
440
-  omniauth-github
441
-  omniauth-tumblr
442 443
   omniauth-twitter
443 444
   pg
444 445
   protected_attributes (~> 1.0.8)
@@ -466,6 +467,7 @@ DEPENDENCIES
466 467
   twilio-ruby (~> 3.11.5)
467 468
   twitter (~> 5.8.0)
468 469
   typhoeus (~> 0.6.3)
470
+  tzinfo (>= 1.2.0)
469 471
   tzinfo-data
470 472
   uglifier (>= 1.3.0)
471 473
   unicorn

+ 1 - 0
README.md

@@ -55,6 +55,7 @@ If you just want to play around, you can simply fork this repository, then perfo
55 55
 
56 56
 * Run `git remote add upstream https://github.com/cantino/huginn.git` to add the main repository as a remote for your fork.
57 57
 * Copy `.env.example` to `.env` (`cp .env.example .env`) and edit `.env`, at least updating the `APP_SECRET_TOKEN` variable.
58
+* Run `bundle` to install dependencies
58 59
 * Run `rake db:create`, `rake db:migrate`, and then `rake db:seed` to create a development MySQL database with some example Agents.
59 60
 * Run `foreman start`, visit [http://localhost:3000/][localhost], and login with the username of `admin` and the password of `password`.
60 61
 * Setup some Agents!

+ 12 - 0
app/assets/javascripts/application.js.coffee

@@ -0,0 +1,12 @@
1
+#= require jquery
2
+#= require jquery_ujs
3
+#= require typeahead.bundle
4
+#= require bootstrap
5
+#= require select2
6
+#= require json2
7
+#= require jquery.json-editor
8
+#= require latlon_and_geo
9
+#= require spectrum
10
+#= require_tree ./components
11
+#= require_tree ./pages
12
+#= require_self

+ 0 - 226
app/assets/javascripts/application.js.coffee.erb

@@ -1,226 +0,0 @@
1
-#= require jquery
2
-#= require jquery_ujs
3
-#= require typeahead.bundle
4
-#= require bootstrap
5
-#= require select2
6
-#= require json2
7
-#= require jquery.json-editor
8
-#= require latlon_and_geo
9
-#= require spectrum
10
-#= require ./worker-checker
11
-#= require_self
12
-
13
-window.setupJsonEditor = ($editors = $(".live-json-editor")) ->
14
-  JSONEditor.prototype.ADD_IMG = '<%= image_path 'json-editor/add.png' %>'
15
-  JSONEditor.prototype.DELETE_IMG = '<%= image_path 'json-editor/delete.png' %>'
16
-  editors = []
17
-  $editors.each ->
18
-    $editor = $(this)
19
-    jsonEditor = new JSONEditor($editor, $editor.data('width') || 400, $editor.data('height') || 500)
20
-    jsonEditor.doTruncation true
21
-    jsonEditor.showFunctionButtons()
22
-    editors.push jsonEditor
23
-  return editors
24
-
25
-hideSchedule = ->
26
-  $(".schedule-region .can-be-scheduled").hide()
27
-  $(".schedule-region .cannot-be-scheduled").show()
28
-
29
-showSchedule = (defaultSchedule = null) ->
30
-  if defaultSchedule?
31
-    $(".schedule-region select").val(defaultSchedule).change()
32
-  $(".schedule-region .can-be-scheduled").show()
33
-  $(".schedule-region .cannot-be-scheduled").hide()
34
-
35
-hideLinks = ->
36
-  $(".link-region .select2-container").hide()
37
-  $(".link-region .propagate-immediately").hide()
38
-  $(".link-region .cannot-receive-events").show()
39
-
40
-showLinks = ->
41
-  $(".link-region .select2-container").show()
42
-  $(".link-region .propagate-immediately").show()
43
-  $(".link-region .cannot-receive-events").hide()
44
-  showEventDescriptions()
45
-
46
-hideControlLinks = ->
47
-  $(".control-link-region").hide()
48
-
49
-showControlLinks = ->
50
-  $(".control-link-region").show()
51
-
52
-hideEventCreation = ->
53
-  $(".event-related-region").hide()
54
-
55
-showEventCreation = ->
56
-  $(".event-related-region").show()
57
-
58
-showEventDescriptions = ->
59
-  if $("#agent_source_ids").val()
60
-    $.getJSON "/agents/event_descriptions", { ids: $("#agent_source_ids").val().join(",") }, (json) =>
61
-      if json.description_html?
62
-        $(".event-descriptions").show().html(json.description_html)
63
-      else
64
-        $(".event-descriptions").hide()
65
-  else
66
-    $(".event-descriptions").html("").hide()
67
-
68
-$(document).ready ->
69
-  $('.navbar .dropdown.dropdown-hover').hover \
70
-    -> $(this).addClass('open'),
71
-    -> $(this).removeClass('open')
72
-
73
-  # JSON Editor
74
-  window.jsonEditor = setupJsonEditor()[0]
75
-
76
-  # Flash
77
-  if $(".flash").length
78
-    setTimeout((-> $(".flash").slideUp(-> $(".flash").remove())), 5000)
79
-
80
-  # Help popovers
81
-  $('.hover-help').popover(trigger: 'hover', html: true)
82
-
83
-  # Agent Navigation
84
-  $agentNavigate = $('#agent-navigate')
85
-
86
-  # initialize typeahead listener
87
-  $agentNavigate.bind "typeahead:selected", (event, object, name) ->
88
-    item = object['value']
89
-    $agentNavigate.typeahead('val', '')
90
-    if agentPaths[item]
91
-      $(".spinner").show()
92
-      navigationData = agentPaths[item]
93
-      if !(navigationData instanceof Object) || !navigationData.method || navigationData.method == 'GET'
94
-        window.location = navigationData.url || navigationData
95
-      else
96
-        $("<a href='#{navigationData.url}' data-method='#{navigationData.method}'></a>").appendTo($("body")).click()
97
-
98
-  # substring matcher for typeahead
99
-  substringMatcher = (strings)->
100
-    findMatches = (query, callback) ->
101
-      matches = []
102
-      substrRegex = new RegExp(query, "i")
103
-      $.each strings, (i, str) ->
104
-        matches.push value: str  if substrRegex.test(str)
105
-      callback(matches.slice(0,6))
106
-
107
-  $agentNavigate.typeahead
108
-    minLength: 1,
109
-    highlight: true,
110
-  ,
111
-    source: substringMatcher(agentNames)
112
-
113
-
114
-  # Pressing '/' selects the search box.
115
-  $("body").on "keypress", (e) ->
116
-    if e.keyCode == 47 # The '/' key
117
-      if e.target.nodeName == "BODY"
118
-        e.preventDefault()
119
-        $agentNavigate.focus()
120
-
121
-  # Agent Show
122
-  fetchLogs = (e) ->
123
-    agentId = $(e.target).closest("[data-agent-id]").data("agent-id")
124
-    e.preventDefault()
125
-    $("#logs .spinner").show()
126
-    $("#logs .refresh, #logs .clear").hide()
127
-    $.get "/agents/#{agentId}/logs", (html) =>
128
-      $("#logs .logs").html html
129
-      $("#logs .spinner").stop(true, true).fadeOut ->
130
-        $("#logs .refresh, #logs .clear").show()
131
-
132
-  clearLogs = (e) ->
133
-    if confirm("Are you sure you want to clear all logs for this Agent?")
134
-      agentId = $(e.target).closest("[data-agent-id]").data("agent-id")
135
-      e.preventDefault()
136
-      $("#logs .spinner").show()
137
-      $("#logs .refresh, #logs .clear").hide()
138
-      $.post "/agents/#{agentId}/logs/clear", { "_method": "DELETE" }, (html) =>
139
-        $("#logs .logs").html html
140
-        $("#show-tabs li a.recent-errors").removeClass 'recent-errors'
141
-        $("#logs .spinner").stop(true, true).fadeOut ->
142
-          $("#logs .refresh, #logs .clear").show()
143
-
144
-  $(".agent-show #show-tabs a[href='#logs'], #logs .refresh").on "click", fetchLogs
145
-  $(".agent-show #logs .clear").on "click", clearLogs
146
-
147
-  if tab = window.location.href.match(/tab=(\w+)\b/i)?[1]
148
-    if tab in ["details", "logs"]
149
-      $(".agent-show .nav-pills li a[href='##{tab}']").click()
150
-
151
-  # Editing Agents
152
-  $("#agent_source_ids").on "change", showEventDescriptions
153
-
154
-  $("#agent_type").on "change", ->
155
-    if window.jsonEditor?
156
-      $("#agent-spinner").fadeIn();
157
-      $("#agent_source_ids").select2("val", {});
158
-      $(".event-descriptions").html("").hide()
159
-      $.getJSON "/agents/type_details", { type: $(@).val() }, (json) =>
160
-        if json.can_be_scheduled
161
-          showSchedule(json.default_schedule)
162
-        else
163
-          hideSchedule()
164
-
165
-        if json.can_receive_events
166
-          showLinks()
167
-        else
168
-          hideLinks()
169
-
170
-        if json.can_control_other_agents
171
-          showControlLinks()
172
-        else
173
-          hideControlLinks()
174
-
175
-        if json.can_create_events
176
-          showEventCreation()
177
-        else
178
-          hideEventCreation()
179
-
180
-        $(".description").html(json.description_html) if json.description_html?
181
-
182
-        $('.oauthable-form').html(json.form) if json.form?
183
-
184
-        if $("#agent_options").hasClass("showing-default") || $("#agent_options").val().match(/\A\s*(\{\s*\}|)\s*\Z/g)
185
-          window.jsonEditor.json = json.options
186
-          window.jsonEditor.rebuild()
187
-
188
-        $("#agent-spinner").stop(true, true).fadeOut();
189
-
190
-  $("#agent_type").change() if $("#agent_type").length
191
-
192
-  # Select2 Selects
193
-  $(".select2").select2(width: 'resolve')
194
-
195
-  if $(".schedule-region")
196
-    if $(".schedule-region").data("can-be-scheduled") == true
197
-      showSchedule()
198
-    else
199
-      hideSchedule()
200
-
201
-  if $(".link-region")
202
-    if $(".link-region").data("can-receive-events") == true
203
-      showLinks()
204
-    else
205
-      hideLinks()
206
-
207
-  if $(".control-link-region")
208
-    if $(".control-link-region").data("can-control-other-agents") == true
209
-      showControlLinks()
210
-    else
211
-      hideControlLinks()
212
-
213
-  if $(".event-related-region")
214
-    if $(".event-related-region").data("can-create-events") == true
215
-      showEventCreation()
216
-    else
217
-      hideEventCreation()
218
-
219
-  $('.selectable-text').each ->
220
-    $(this).click ->
221
-      range = document.createRange()
222
-      range.setStartBefore(this.firstChild)
223
-      range.setEndAfter(this.lastChild)
224
-      sel = window.getSelection()
225
-      sel.removeAllRanges();
226
-      sel.addRange(range)

+ 30 - 0
app/assets/javascripts/components/core.js.coffee

@@ -0,0 +1,30 @@
1
+$ ->
2
+  # Flash
3
+  if $(".flash").length
4
+    setTimeout((-> $(".flash").slideUp(-> $(".flash").remove())), 5000)
5
+
6
+  # Help popovers
7
+  $('.hover-help').popover(trigger: 'hover', html: true)
8
+
9
+  # Pressing '/' selects the search box.
10
+  $("body").on "keypress", (e) ->
11
+    if e.keyCode == 47 # The '/' key
12
+      if e.target.nodeName == "BODY"
13
+        e.preventDefault()
14
+        $agentNavigate.focus()
15
+
16
+  # Select2 Selects
17
+  $(".select2").select2(width: 'resolve')
18
+
19
+  # Helper for selecting text when clicked
20
+  $('.selectable-text').each ->
21
+    $(this).click ->
22
+      range = document.createRange()
23
+      range.setStartBefore(this.firstChild)
24
+      range.setEndAfter(this.lastChild)
25
+      sel = window.getSelection()
26
+      sel.removeAllRanges();
27
+      sel.addRange(range)
28
+
29
+  # Agent navbar dropdown
30
+  $('.navbar .dropdown.dropdown-hover').hover (-> $(this).addClass('open')), (-> $(this).removeClass('open'))

+ 14 - 0
app/assets/javascripts/components/json-editor.js.coffee.erb

@@ -0,0 +1,14 @@
1
+window.setupJsonEditor = ($editors = $(".live-json-editor")) ->
2
+  JSONEditor.prototype.ADD_IMG = '<%= image_path 'json-editor/add.png' %>'
3
+  JSONEditor.prototype.DELETE_IMG = '<%= image_path 'json-editor/delete.png' %>'
4
+  editors = []
5
+  $editors.each ->
6
+    $editor = $(this)
7
+    jsonEditor = new JSONEditor($editor, $editor.data('width') || 400, $editor.data('height') || 500)
8
+    jsonEditor.doTruncation true
9
+    jsonEditor.showFunctionButtons()
10
+    editors.push jsonEditor
11
+  return editors
12
+
13
+$ ->
14
+  window.jsonEditor = setupJsonEditor()[0]

+ 29 - 0
app/assets/javascripts/components/search.js.coffee

@@ -0,0 +1,29 @@
1
+$ ->
2
+  $agentNavigate = $('#agent-navigate')
3
+
4
+  # initialize typeahead listener
5
+  $agentNavigate.bind "typeahead:selected", (event, object, name) ->
6
+    item = object['value']
7
+    $agentNavigate.typeahead('val', '')
8
+    if window.agentPaths[item]
9
+      $(".spinner").show()
10
+      navigationData = window.agentPaths[item]
11
+      if !(navigationData instanceof Object) || !navigationData.method || navigationData.method == 'GET'
12
+        window.location = navigationData.url || navigationData
13
+      else
14
+        $("<a href='#{navigationData.url}' data-method='#{navigationData.method}'></a>").appendTo($("body")).click()
15
+
16
+  # substring matcher for typeahead
17
+  substringMatcher = (strings) ->
18
+    findMatches = (query, callback) ->
19
+      matches = []
20
+      substrRegex = new RegExp(query, "i")
21
+      $.each strings, (i, str) ->
22
+        matches.push value: str  if substrRegex.test(str)
23
+      callback(matches.slice(0,6))
24
+
25
+  $agentNavigate.typeahead
26
+    minLength: 1,
27
+    highlight: true,
28
+  ,
29
+    source: substringMatcher(window.agentNames)

+ 14 - 0
app/assets/javascripts/components/utils.js.coffee

@@ -0,0 +1,14 @@
1
+class @Utils
2
+  @navigatePath: (path) ->
3
+    path = "/" + path unless path.match(/^\//)
4
+    window.location.href = path
5
+
6
+  @currentPath: ->
7
+    window.location.href.replace(/https?:\/\/.*?\//g, '')
8
+
9
+  @registerPage: (klass, options = {}) ->
10
+    if options.forPathsMatching?
11
+      if Utils.currentPath().match(options.forPathsMatching)
12
+        window.currentPage = new klass()
13
+    else
14
+      new klass()

app/assets/javascripts/worker-checker.js.coffee → app/assets/javascripts/components/worker-checker.js.coffee


+ 2 - 0
app/assets/javascripts/diagram.js.coffee

@@ -1,3 +1,5 @@
1
+# This is not included in the core application.js bundle.
2
+
1 3
 $ ->
2 4
   svg = document.querySelector('.agent-diagram svg.diagram')
3 5
   overlay = document.querySelector('.agent-diagram .overlay')

+ 2 - 0
app/assets/javascripts/graphing.js.coffee

@@ -2,6 +2,8 @@
2 2
 #= require rickshaw
3 3
 #= require_self
4 4
 
5
+# This is not included in the core application.js bundle.
6
+
5 7
 window.renderGraph = ($chart, data, peaks, name) ->
6 8
   graph = new Rickshaw.Graph
7 9
     element: $chart.find(".chart").get(0)

+ 41 - 0
app/assets/javascripts/map_marker.js.coffee

@@ -0,0 +1,41 @@
1
+window.map_marker = (map, options = {}) ->
2
+  pos = new google.maps.LatLng(options.lat, options.lng)
3
+
4
+  if options.radius > 0
5
+    new google.maps.Circle
6
+      map: map
7
+      strokeColor: '#FF0000'
8
+      strokeOpacity: 0.8
9
+      strokeWeight: 2
10
+      fillColor: '#FF0000'
11
+      fillOpacity: 0.35
12
+      center: pos
13
+      radius: options.radius
14
+  else
15
+    new google.maps.Marker
16
+      map: map
17
+      position: pos
18
+      title: 'Recorded Location'
19
+
20
+  if options.course
21
+    p1 = new LatLon(pos.lat(), pos.lng())
22
+    speed = options.speed ? 1
23
+    p2 = p1.destinationPoint(options.course, Math.max(0.2, speed) * 0.1)
24
+
25
+    lineCoordinates = [
26
+      pos
27
+      new google.maps.LatLng(p2.lat(), p2.lon())
28
+    ]
29
+
30
+    lineSymbol =
31
+      path: google.maps.SymbolPath.FORWARD_CLOSED_ARROW
32
+
33
+    new google.maps.Polyline
34
+      map: map
35
+      path: lineCoordinates
36
+      icons: [
37
+        {
38
+          icon: lineSymbol
39
+          offset: '100%'
40
+        }
41
+      ]

+ 126 - 0
app/assets/javascripts/pages/agent-edit-page.js.coffee

@@ -0,0 +1,126 @@
1
+class @AgentEditPage
2
+  constructor: ->
3
+    $("#agent_source_ids").on "change", @showEventDescriptions
4
+    @showCorrectRegionsOnStartup()
5
+
6
+    # The type selector is only available on the new agent form.
7
+    if $("#agent_type").length
8
+      $("#agent_type").on "change", => @handleTypeChange(false)
9
+      @handleTypeChange(true)
10
+
11
+  handleTypeChange: (firstTime) ->
12
+    $(".event-descriptions").html("").hide()
13
+    type = $('#agent_type').val()
14
+
15
+    if type == 'Agent'
16
+      $(".agent-settings").hide()
17
+      $(".description").hide()
18
+    else
19
+      $(".agent-settings").show()
20
+      $("#agent-spinner").fadeIn()
21
+      $("#agent_source_ids").select2("val", {})
22
+      $(".model-errors").hide() unless firstTime
23
+      $.getJSON "/agents/type_details", { type: type }, (json) =>
24
+        if json.can_be_scheduled
25
+          if firstTime
26
+            @showSchedule()
27
+          else
28
+            @showSchedule(json.default_schedule)
29
+        else
30
+          @hideSchedule()
31
+
32
+        if json.can_receive_events
33
+          @showLinks()
34
+        else
35
+          @hideLinks()
36
+
37
+        if json.can_control_other_agents
38
+          @showControlLinks()
39
+        else
40
+          @hideControlLinks()
41
+
42
+        if json.can_create_events
43
+          @showEventCreation()
44
+        else
45
+          @hideEventCreation()
46
+
47
+        $(".description").show().html(json.description_html) if json.description_html?
48
+
49
+        $('.oauthable-form').html(json.form) if json.form?
50
+
51
+        unless firstTime
52
+          window.jsonEditor.json = json.options
53
+          window.jsonEditor.rebuild()
54
+
55
+        $("#agent-spinner").stop(true, true).fadeOut();
56
+
57
+  hideSchedule: ->
58
+    $(".schedule-region .can-be-scheduled").hide()
59
+    $(".schedule-region .cannot-be-scheduled").show()
60
+
61
+  showSchedule: (defaultSchedule = null) ->
62
+    if defaultSchedule?
63
+      $(".schedule-region select").val(defaultSchedule).change()
64
+    $(".schedule-region .can-be-scheduled").show()
65
+    $(".schedule-region .cannot-be-scheduled").hide()
66
+
67
+  hideLinks: ->
68
+    $(".link-region .select2-container").hide()
69
+    $(".link-region .propagate-immediately").hide()
70
+    $(".link-region .cannot-receive-events").show()
71
+
72
+  showLinks: ->
73
+    $(".link-region .select2-container").show()
74
+    $(".link-region .propagate-immediately").show()
75
+    $(".link-region .cannot-receive-events").hide()
76
+    @showEventDescriptions()
77
+
78
+  hideControlLinks: ->
79
+    $(".control-link-region").hide()
80
+
81
+  showControlLinks: ->
82
+    $(".control-link-region").show()
83
+
84
+  hideEventCreation: ->
85
+    $(".event-related-region").hide()
86
+
87
+  showEventCreation: ->
88
+    $(".event-related-region").show()
89
+
90
+  showEventDescriptions: ->
91
+    if $("#agent_source_ids").val()
92
+      $.getJSON "/agents/event_descriptions", { ids: $("#agent_source_ids").val().join(",") }, (json) =>
93
+        if json.description_html?
94
+          $(".event-descriptions").show().html(json.description_html)
95
+        else
96
+          $(".event-descriptions").hide()
97
+    else
98
+      $(".event-descriptions").html("").hide()
99
+
100
+  showCorrectRegionsOnStartup: ->
101
+    if $(".schedule-region")
102
+      if $(".schedule-region").data("can-be-scheduled") == true
103
+        @showSchedule()
104
+      else
105
+        @hideSchedule()
106
+
107
+    if $(".link-region")
108
+      if $(".link-region").data("can-receive-events") == true
109
+        @showLinks()
110
+      else
111
+        @hideLinks()
112
+
113
+    if $(".control-link-region")
114
+      if $(".control-link-region").data("can-control-other-agents") == true
115
+        @showControlLinks()
116
+      else
117
+        @hideControlLinks()
118
+
119
+    if $(".event-related-region")
120
+      if $(".event-related-region").data("can-create-events") == true
121
+        @showEventCreation()
122
+      else
123
+        @hideEventCreation()
124
+
125
+$ ->
126
+  Utils.registerPage(AgentEditPage, forPathsMatching: /^agents/)

+ 35 - 0
app/assets/javascripts/pages/agent-show-page.js.coffee

@@ -0,0 +1,35 @@
1
+class @AgentShowPage
2
+  constructor: ->
3
+    $(".agent-show #show-tabs a[href='#logs'], #logs .refresh").on "click", @fetchLogs
4
+    $(".agent-show #logs .clear").on "click", @clearLogs
5
+
6
+    # Trigger tabs when navigated to.
7
+    if tab = window.location.href.match(/tab=(\w+)\b/i)?[1]
8
+      if tab in ["details", "logs"]
9
+        $(".agent-show .nav-pills li a[href='##{tab}']").click()
10
+
11
+  fetchLogs: (e) ->
12
+    agentId = $(e.target).closest("[data-agent-id]").data("agent-id")
13
+    e.preventDefault()
14
+    $("#logs .spinner").show()
15
+    $("#logs .refresh, #logs .clear").hide()
16
+    $.get "/agents/#{agentId}/logs", (html) =>
17
+      $("#logs .logs").html html
18
+      $("#logs .spinner").stop(true, true).fadeOut ->
19
+        $("#logs .refresh, #logs .clear").show()
20
+
21
+  clearLogs: (e) ->
22
+    if confirm("Are you sure you want to clear all logs for this Agent?")
23
+      agentId = $(e.target).closest("[data-agent-id]").data("agent-id")
24
+      e.preventDefault()
25
+      $("#logs .spinner").show()
26
+      $("#logs .refresh, #logs .clear").hide()
27
+      $.post "/agents/#{agentId}/logs/clear", { "_method": "DELETE" }, (html) =>
28
+        $("#logs .logs").html html
29
+        $("#show-tabs li a.recent-errors").removeClass 'recent-errors'
30
+        $("#logs .spinner").stop(true, true).fadeOut ->
31
+          $("#logs .refresh, #logs .clear").show()
32
+
33
+$ ->
34
+  Utils.registerPage(AgentShowPage, forPathsMatching: /^agents\/\d+/)
35
+

+ 2 - 0
app/assets/javascripts/user_credentials.js.coffee

@@ -3,6 +3,8 @@
3 3
 #= require ace/mode-markdown.js
4 4
 #= require_self
5 5
 
6
+# This is not included in the core application.js bundle.
7
+
6 8
 $ ->
7 9
   editor = ace.edit("ace-credential-value")
8 10
   editor.getSession().setTabSize(2)

+ 43 - 1
app/assets/stylesheets/application.css.scss.erb

@@ -18,6 +18,8 @@
18 18
  */
19 19
 
20 20
 @import "bootstrap";
21
+@import "font-awesome-sprockets";
22
+@import "font-awesome";
21 23
 
22 24
 body { padding-top: 60px; }
23 25
 
@@ -86,6 +88,11 @@ span.not-applicable:after {
86 88
 .nav > li {
87 89
   &.job-indicator, &#event-indicator {
88 90
     display: none;
91
+
92
+    a {
93
+      padding-right: 5px;
94
+      padding-left: 5px;
95
+    }
89 96
   }
90 97
 }
91 98
 
@@ -170,7 +177,7 @@ span.not-applicable:after {
170 177
 
171 178
 // Disabled
172 179
 
173
-.agent-disabled {
180
+.agent-unavailable {
174 181
   opacity: 0.5;
175 182
 }
176 183
 
@@ -232,3 +239,38 @@ h2 .scenario, a span.label.scenario {
232 239
 .confirm-agent .popover {
233 240
   width: 200px;
234 241
 }
242
+
243
+.btn-auth {
244
+  position: relative;
245
+  padding-left: 40px;
246
+  $border-color: rgba(0,0,0,0.2);
247
+  border-color: $border-color;
248
+
249
+  > i:first-child {
250
+    position: absolute;
251
+    top: 0;
252
+    left: 0;
253
+    bottom: 0;
254
+    width: 32px;
255
+    height: 32px;
256
+    text-align: center;
257
+    line-height: 32px;
258
+    font-size: 24px;
259
+    border-right: 1px solid $border-color;
260
+  }
261
+
262
+  &.btn-auth-twitter {
263
+    color: #fff;
264
+    background-color: #55acee;
265
+  }
266
+
267
+  &.btn-auth-37signals {
268
+    color: #fff;
269
+    background-color: #8fc857;
270
+  }
271
+
272
+  &.btn-auth-github {
273
+    color: #fff;
274
+    background-color: #444;
275
+  }
276
+}

+ 5 - 5
app/assets/stylesheets/tables.css.scss

@@ -6,17 +6,17 @@
6 6
   &.asc:after, &.desc:after {
7 7
     text-decoration: none;
8 8
     position: absolute;
9
-    top: -5px;
10
-    right: -12px;
11
-    font-size: 1.2em;
9
+    top: 0;
10
+    right: -1em;
11
+    font-family: FontAwesome;
12 12
   }
13 13
 
14 14
   &.asc:after {
15
-    content: '\2193';
15
+    content: '\f0de'; //fa-sort-asc
16 16
   }
17 17
 
18 18
   &.desc:after {
19
-    content: '\2191';
19
+    content: '\f0dd'; //fa-sort-desc
20 20
   }
21 21
 }
22 22
 

+ 9 - 1
app/concerns/twitter_concern.rb

@@ -5,7 +5,9 @@ module TwitterConcern
5 5
     include Oauthable
6 6
 
7 7
     validate :validate_twitter_options
8
-    valid_oauth_providers :twitter
8
+    valid_oauth_providers 'twitter'
9
+
10
+    gem_dependency_check { defined?(Twitter) && has_oauth_configuration_for?('twitter') }
9 11
   end
10 12
 
11 13
   def validate_twitter_options
@@ -41,4 +43,10 @@ module TwitterConcern
41 43
       config.access_token_secret = twitter_oauth_token_secret
42 44
     end
43 45
   end
46
+
47
+  module ClassMethods
48
+    def twitter_dependencies_missing
49
+      "## Include the `twitter`, `omniauth-twitter`, and `cantino-twitter-stream` gems in your Gemfile to use Twitter Agents."
50
+    end
51
+  end
44 52
 end

+ 2 - 4
app/concerns/weibo_concern.rb

@@ -2,6 +2,8 @@ module WeiboConcern
2 2
   extend ActiveSupport::Concern
3 3
 
4 4
   included do
5
+    gem_dependency_check { defined?(WeiboOAuth2) }
6
+
5 7
     self.validate :validate_weibo_options
6 8
   end
7 9
 
@@ -22,8 +24,4 @@ module WeiboConcern
22 24
     end
23 25
     @weibo_client
24 26
   end
25
-
26
-  module ClassMethods
27
-
28
-  end
29 27
 end

+ 1 - 1
app/controllers/agents_controller.rb

@@ -43,7 +43,7 @@ class AgentsController < ApplicationController
43 43
         :can_control_other_agents => @agent.can_control_other_agents?,
44 44
         :options => @agent.default_options,
45 45
         :description_html => @agent.html_description,
46
-        :form => render_to_string(partial: 'oauth_dropdown')
46
+        :form => render_to_string(partial: 'oauth_dropdown', locals: { agent: @agent })
47 47
     }
48 48
   end
49 49
 

+ 3 - 1
app/controllers/user_credentials_controller.rb

@@ -8,7 +8,9 @@ class UserCredentialsController < ApplicationController
8 8
 
9 9
     respond_to do |format|
10 10
       format.html
11
-      format.json { render json: @user_credentials }
11
+      format.json {
12
+        send_data Utils.pretty_jsonify(@user_credentials.limit(nil).as_json), disposition: 'attachment'
13
+      }
12 14
     end
13 15
   end
14 16
 

+ 2 - 0
app/helpers/application_helper.rb

@@ -32,6 +32,8 @@ module ApplicationHelper
32 32
   def working(agent)
33 33
     if agent.disabled?
34 34
       link_to 'Disabled', agent_path(agent), class: 'label label-warning'
35
+    elsif agent.dependencies_missing?
36
+      content_tag :span, 'Missing Gems', class: 'label label-danger'
35 37
     elsif agent.working?
36 38
       content_tag :span, 'Yes', class: 'label label-success'
37 39
     else

+ 5 - 5
app/helpers/dot_helper.rb

@@ -137,9 +137,9 @@ module DotHelper
137 137
              label: agent_label[agent],
138 138
              tooltip: (agent.short_type.titleize if rich),
139 139
              URL: (agent_url[agent] if rich),
140
-             style: ('rounded,dashed' if agent.disabled?),
141
-             color: (@disabled if agent.disabled?),
142
-             fontcolor: (@disabled if agent.disabled?))
140
+             style: ('rounded,dashed' if agent.unavailable?),
141
+             color: (@disabled if agent.unavailable?),
142
+             fontcolor: (@disabled if agent.unavailable?))
143 143
       end
144 144
 
145 145
       def agent_edge(agent, receiver)
@@ -148,7 +148,7 @@ module DotHelper
148 148
              style: ('dashed' unless receiver.propagate_immediately?),
149 149
              label: (" #{agent.control_action}s " if agent.can_control_other_agents?),
150 150
              arrowhead: ('empty' if agent.can_control_other_agents?),
151
-             color: (@disabled if agent.disabled? || receiver.disabled?))
151
+             color: (@disabled if agent.unavailable? || receiver.unavailable?))
152 152
       end
153 153
 
154 154
       block('digraph', 'Agent Event Flow') {
@@ -218,7 +218,7 @@ module DotHelper
218 218
             # a dummy label only to obtain the background color
219 219
             label['class'] = [
220 220
               'label',
221
-              if agent.disabled?
221
+              if agent.unavailable?
222 222
                 'label-warning'
223 223
               elsif agent.working?
224 224
                 'label-success'

+ 0 - 5
app/helpers/service_helper.rb

@@ -1,5 +0,0 @@
1
-module ServiceHelper
2
-  def has_oauth_configuration_for(provider)
3
-    ENV["#{provider.upcase}_OAUTH_KEY"].present? && ENV["#{provider.upcase}_OAUTH_SECRET"].present?
4
-  end
5
-end

+ 19 - 2
app/models/agent.rb

@@ -150,6 +150,14 @@ class Agent < ActiveRecord::Base
150 150
     end
151 151
   end
152 152
 
153
+  def unavailable?
154
+    disabled? || dependencies_missing?
155
+  end
156
+
157
+  def dependencies_missing?
158
+    self.class.dependencies_missing?
159
+  end
160
+
153 161
   def default_schedule
154 162
     self.class.default_schedule
155 163
   end
@@ -317,6 +325,15 @@ class Agent < ActiveRecord::Base
317 325
       include? AgentControllerConcern
318 326
     end
319 327
 
328
+    def gem_dependency_check
329
+      @gem_dependencies_checked = true
330
+      @gem_dependencies_met = yield
331
+    end
332
+
333
+    def dependencies_missing?
334
+      @gem_dependencies_checked && !@gem_dependencies_met
335
+    end
336
+
320 337
     # Find all Agents that have received Events since the last execution of this method.  Update those Agents with
321 338
     # their new `last_checked_event_id` and queue each of the Agents to be called with #receive using `async_receive`.
322 339
     # This is called by bin/schedule.rb periodically.
@@ -362,7 +379,7 @@ class Agent < ActiveRecord::Base
362 379
     def async_receive(agent_id, event_ids)
363 380
       agent = Agent.find(agent_id)
364 381
       begin
365
-        return if agent.disabled?
382
+        return if agent.unavailable?
366 383
         agent.receive(Event.where(:id => event_ids))
367 384
         agent.last_receive_at = Time.now
368 385
         agent.save!
@@ -400,7 +417,7 @@ class Agent < ActiveRecord::Base
400 417
     def async_check(agent_id)
401 418
       agent = Agent.find(agent_id)
402 419
       begin
403
-        return if agent.disabled?
420
+        return if agent.unavailable?
404 421
         agent.check
405 422
         agent.last_check_at = Time.now
406 423
         agent.save!

+ 0 - 1
app/models/agents/adioso_agent.rb

@@ -1,6 +1,5 @@
1 1
 module Agents
2 2
   class AdiosoAgent < Agent
3
-
4 3
     cannot_receive_events!
5 4
 
6 5
   	default_schedule "every_1d"

+ 9 - 9
app/models/agents/ftpsite_agent.rb

@@ -1,15 +1,15 @@
1
-require 'net/ftp'
2
-require 'net/ftp/list'
3 1
 require 'uri'
4 2
 require 'time'
5 3
 
6 4
 module Agents
7 5
   class FtpsiteAgent < Agent
8 6
     cannot_receive_events!
9
-
10 7
     default_schedule "every_12h"
11 8
 
9
+    gem_dependency_check { defined?(Net::FTP) && defined?(Net::FTP::List) }
10
+
12 11
     description <<-MD
12
+      #{'## Include `net-ftp-list` in your Gemfile to use this Agent!' if dependencies_missing?}
13 13
       The FtpsiteAgent checks a FTP site and creates Events based on newly uploaded files in a directory.
14 14
 
15 15
       Specify a `url` that represents a directory of an FTP site to watch, and a list of `patterns` to match against file names.
@@ -35,12 +35,12 @@ module Agents
35 35
 
36 36
     def default_options
37 37
       {
38
-          'expected_update_period_in_days' => "1",
39
-          'url' => "ftp://example.org/pub/releases/",
40
-          'patterns' => [
41
-            'foo-*.tar.gz',
42
-          ],
43
-          'after' => Time.now.iso8601,
38
+        'expected_update_period_in_days' => "1",
39
+        'url' => "ftp://example.org/pub/releases/",
40
+        'patterns' => [
41
+          'foo-*.tar.gz',
42
+        ],
43
+        'after' => Time.now.iso8601,
44 44
       }
45 45
     end
46 46
 

+ 3 - 0
app/models/agents/google_calendar_publish_agent.rb

@@ -4,7 +4,10 @@ module Agents
4 4
   class GoogleCalendarPublishAgent < Agent
5 5
     cannot_be_scheduled!
6 6
 
7
+    gem_dependency_check { defined?(GoogleCalendar) }
8
+
7 9
     description <<-MD
10
+      #{'## Include `google-api-client` in your Gemfile to use this Agent!' if dependencies_missing?}
8 11
       The GoogleCalendarPublishAgent creates events on your google calendar.
9 12
 
10 13
       This agent relies on service accounts, rather than oauth.

+ 5 - 4
app/models/agents/growl_agent.rb

@@ -1,5 +1,3 @@
1
-require 'ruby-growl'
2
-
3 1
 module Agents
4 2
   class GrowlAgent < Agent
5 3
     attr_reader :growler
@@ -7,7 +5,10 @@ module Agents
7 5
     cannot_be_scheduled!
8 6
     cannot_create_events!
9 7
 
8
+    gem_dependency_check { defined?(Growl) }
9
+
10 10
     description <<-MD
11
+      #{'## Include `ruby-growl` in your Gemfile to use this Agent!' if dependencies_missing?}
11 12
       The GrowlAgent sends any events it receives to a Growl GNTP server immediately.
12 13
       
13 14
       It is assumed that events have a `message` or `text` key, which will hold the body of the growl notification, and a `subject` key, which will have the headline of the Growl notification. You can use Event Formatting Agent if your event does not provide these keys.
@@ -34,13 +35,13 @@ module Agents
34 35
         errors.add(:base, "growl_server and expected_receive_period_in_days are required fields")
35 36
       end
36 37
     end
37
-    
38
+
38 39
     def register_growl
39 40
       @growler = Growl.new interpolated['growl_server'], interpolated['growl_app_name'], "GNTP"
40 41
       @growler.password = interpolated['growl_password']
41 42
       @growler.add_notification interpolated['growl_notification_name']
42 43
     end
43
-    
44
+
44 45
     def notify_growl(subject, message)
45 46
       @growler.notify(interpolated['growl_notification_name'], subject, message)
46 47
     end

+ 7 - 1
app/models/agents/hipchat_agent.rb

@@ -3,7 +3,10 @@ module Agents
3 3
     cannot_be_scheduled!
4 4
     cannot_create_events!
5 5
 
6
+    gem_dependency_check { defined?(HipChat) }
7
+
6 8
     description <<-MD
9
+      #{'## Include `hipchat` in your Gemfile to use this Agent!' if dependencies_missing?}
7 10
       The HipchatAgent sends messages to a Hipchat Room
8 11
 
9 12
       To authenticate you need to set the `auth_token`, you can get one at your Hipchat Group Admin page which you can find here:
@@ -40,11 +43,14 @@ module Agents
40 43
     end
41 44
 
42 45
     def receive(incoming_events)
43
-      client = HipChat::Client.new(interpolated[:auth_token] || credential('hipchat_auth_token'))
44 46
       incoming_events.each do |event|
45 47
         mo = interpolated(event)
46 48
         client[mo[:room_name]].send(mo[:username][0..14], mo[:message], :notify => boolify(mo[:notify]), :color => mo[:color])
47 49
       end
48 50
     end
51
+
52
+    def client
53
+      @client ||= HipChat::Client.new(interpolated[:auth_token] || credential('hipchat_auth_token'))
54
+    end
49 55
   end
50 56
 end

+ 212 - 208
app/models/agents/human_task_agent.rb

@@ -1,10 +1,11 @@
1
-require 'rturk'
2
-
3 1
 module Agents
4 2
   class HumanTaskAgent < Agent
5 3
     default_schedule "every_10m"
6 4
 
5
+    gem_dependency_check { defined?(RTurk) }
6
+
7 7
     description <<-MD
8
+      #{'## Include `rturk` in your Gemfile to use this Agent!' if dependencies_missing?}
8 9
       You can use a HumanTaskAgent to create Human Intelligence Tasks (HITs) on Mechanical Turk.
9 10
 
10 11
       HITs can be created in response to events, or on a schedule.  Set `trigger_on` to either `schedule` or `event`.
@@ -226,266 +227,269 @@ module Agents
226 227
 
227 228
     protected
228 229
 
229
-    def take_majority?
230
-      interpolated['combination_mode'] == "take_majority" || interpolated['take_majority'] == "true"
231
-    end
230
+    if defined?(RTurk)
232 231
 
233
-    def create_poll?
234
-      interpolated['combination_mode'] == "poll"
235
-    end
232
+      def take_majority?
233
+        interpolated['combination_mode'] == "take_majority" || interpolated['take_majority'] == "true"
234
+      end
236 235
 
237
-    def event_for_hit(hit_id)
238
-      if memory['hits'][hit_id].is_a?(Hash)
239
-        Event.find_by_id(memory['hits'][hit_id]['event_id'])
240
-      else
241
-        nil
236
+      def create_poll?
237
+        interpolated['combination_mode'] == "poll"
242 238
       end
243
-    end
244 239
 
245
-    def hit_type(hit_id)
246
-      if memory['hits'][hit_id].is_a?(Hash) && memory['hits'][hit_id]['type']
247
-        memory['hits'][hit_id]['type']
248
-      else
249
-        'user'
240
+      def event_for_hit(hit_id)
241
+        if memory['hits'][hit_id].is_a?(Hash)
242
+          Event.find_by_id(memory['hits'][hit_id]['event_id'])
243
+        else
244
+          nil
245
+        end
250 246
       end
251
-    end
252 247
 
253
-    def review_hits
254
-      reviewable_hit_ids = RTurk::GetReviewableHITs.create.hit_ids
255
-      my_reviewed_hit_ids = reviewable_hit_ids & (memory['hits'] || {}).keys
256
-      if reviewable_hit_ids.length > 0
257
-        log "MTurk reports #{reviewable_hit_ids.length} HITs, of which I own [#{my_reviewed_hit_ids.to_sentence}]"
248
+      def hit_type(hit_id)
249
+        if memory['hits'][hit_id].is_a?(Hash) && memory['hits'][hit_id]['type']
250
+          memory['hits'][hit_id]['type']
251
+        else
252
+          'user'
253
+        end
258 254
       end
259 255
 
260
-      my_reviewed_hit_ids.each do |hit_id|
261
-        hit = RTurk::Hit.new(hit_id)
262
-        assignments = hit.assignments
256
+      def review_hits
257
+        reviewable_hit_ids = RTurk::GetReviewableHITs.create.hit_ids
258
+        my_reviewed_hit_ids = reviewable_hit_ids & (memory['hits'] || {}).keys
259
+        if reviewable_hit_ids.length > 0
260
+          log "MTurk reports #{reviewable_hit_ids.length} HITs, of which I own [#{my_reviewed_hit_ids.to_sentence}]"
261
+        end
262
+
263
+        my_reviewed_hit_ids.each do |hit_id|
264
+          hit = RTurk::Hit.new(hit_id)
265
+          assignments = hit.assignments
263 266
 
264
-        log "Looking at HIT #{hit_id}.  I found #{assignments.length} assignments#{" with the statuses: #{assignments.map(&:status).to_sentence}" if assignments.length > 0}"
265
-        if assignments.length == hit.max_assignments && assignments.all? { |assignment| assignment.status == "Submitted" }
266
-          inbound_event = event_for_hit(hit_id)
267
+          log "Looking at HIT #{hit_id}.  I found #{assignments.length} assignments#{" with the statuses: #{assignments.map(&:status).to_sentence}" if assignments.length > 0}"
268
+          if assignments.length == hit.max_assignments && assignments.all? { |assignment| assignment.status == "Submitted" }
269
+            inbound_event = event_for_hit(hit_id)
267 270
 
268
-          if hit_type(hit_id) == 'poll'
269
-            # handle completed polls
271
+            if hit_type(hit_id) == 'poll'
272
+              # handle completed polls
270 273
 
271
-            log "Handling a poll: #{hit_id}"
274
+              log "Handling a poll: #{hit_id}"
272 275
 
273
-            scores = {}
274
-            assignments.each do |assignment|
275
-              assignment.answers.each do |index, rating|
276
-                scores[index] ||= 0
277
-                scores[index] += rating.to_i
276
+              scores = {}
277
+              assignments.each do |assignment|
278
+                assignment.answers.each do |index, rating|
279
+                  scores[index] ||= 0
280
+                  scores[index] += rating.to_i
281
+                end
278 282
               end
279
-            end
280 283
 
281
-            top_answer = scores.to_a.sort {|b, a| a.last <=> b.last }.first.first
284
+              top_answer = scores.to_a.sort {|b, a| a.last <=> b.last }.first.first
282 285
 
283
-            payload = {
284
-              'answers' => memory['hits'][hit_id]['answers'],
285
-              'poll' => assignments.map(&:answers),
286
-              'best_answer' => memory['hits'][hit_id]['answers'][top_answer.to_i - 1]
287
-            }
286
+              payload = {
287
+                'answers' => memory['hits'][hit_id]['answers'],
288
+                'poll' => assignments.map(&:answers),
289
+                'best_answer' => memory['hits'][hit_id]['answers'][top_answer.to_i - 1]
290
+              }
288 291
 
289
-            event = create_event :payload => payload
290
-            log "Event emitted with answer(s) for poll", :outbound_event => event, :inbound_event => inbound_event
291
-          else
292
-            # handle normal completed HITs
293
-            payload = { 'answers' => assignments.map(&:answers) }
294
-
295
-            if take_majority?
296
-              counts = {}
297
-              options['hit']['questions'].each do |question|
298
-                question_counts = question['selections'].inject({}) { |memo, selection| memo[selection['key']] = 0; memo }
299
-                assignments.each do |assignment|
300
-                  answers = ActiveSupport::HashWithIndifferentAccess.new(assignment.answers)
301
-                  answer = answers[question['key']]
302
-                  question_counts[answer] += 1
292
+              event = create_event :payload => payload
293
+              log "Event emitted with answer(s) for poll", :outbound_event => event, :inbound_event => inbound_event
294
+            else
295
+              # handle normal completed HITs
296
+              payload = { 'answers' => assignments.map(&:answers) }
297
+
298
+              if take_majority?
299
+                counts = {}
300
+                options['hit']['questions'].each do |question|
301
+                  question_counts = question['selections'].inject({}) { |memo, selection| memo[selection['key']] = 0; memo }
302
+                  assignments.each do |assignment|
303
+                    answers = ActiveSupport::HashWithIndifferentAccess.new(assignment.answers)
304
+                    answer = answers[question['key']]
305
+                    question_counts[answer] += 1
306
+                  end
307
+                  counts[question['key']] = question_counts
303 308
                 end
304
-                counts[question['key']] = question_counts
305
-              end
306
-              payload['counts'] = counts
309
+                payload['counts'] = counts
307 310
 
308
-              majority_answer = counts.inject({}) do |memo, (key, question_counts)|
309
-                memo[key] = question_counts.to_a.sort {|a, b| a.last <=> b.last }.last.first
310
-                memo
311
-              end
312
-              payload['majority_answer'] = majority_answer
313
-
314
-              if all_questions_are_numeric?
315
-                average_answer = counts.inject({}) do |memo, (key, question_counts)|
316
-                  sum = divisor = 0
317
-                  question_counts.to_a.each do |num, count|
318
-                    sum += num.to_s.to_f * count
319
-                    divisor += count
320
-                  end
321
-                  memo[key] = sum / divisor.to_f
311
+                majority_answer = counts.inject({}) do |memo, (key, question_counts)|
312
+                  memo[key] = question_counts.to_a.sort {|a, b| a.last <=> b.last }.last.first
322 313
                   memo
323 314
                 end
324
-                payload['average_answer'] = average_answer
325
-              end
326
-            end
327
-
328
-            if create_poll?
329
-              questions = []
330
-              selections = 5.times.map { |i| { 'key' => i+1, 'text' => i+1 } }.reverse
331
-              assignments.length.times do |index|
332
-                questions << {
333
-                  'type' => "selection",
334
-                  'name' => "Item #{index + 1}",
335
-                  'key' => index,
336
-                  'required' => "true",
337
-                  'question' => interpolate_string(options['poll_options']['row_template'], assignments[index].answers),
338
-                  'selections' => selections
339
-                }
315
+                payload['majority_answer'] = majority_answer
316
+
317
+                if all_questions_are_numeric?
318
+                  average_answer = counts.inject({}) do |memo, (key, question_counts)|
319
+                    sum = divisor = 0
320
+                    question_counts.to_a.each do |num, count|
321
+                      sum += num.to_s.to_f * count
322
+                      divisor += count
323
+                    end
324
+                    memo[key] = sum / divisor.to_f
325
+                    memo
326
+                  end
327
+                  payload['average_answer'] = average_answer
328
+                end
340 329
               end
341 330
 
342
-              poll_hit = create_hit 'title' => options['poll_options']['title'],
343
-                                    'description' => options['poll_options']['instructions'],
344
-                                    'questions' => questions,
345
-                                    'assignments' => options['poll_options']['assignments'],
346
-                                    'lifetime_in_seconds' => options['poll_options']['lifetime_in_seconds'],
347
-                                    'reward' => options['poll_options']['reward'],
348
-                                    'payload' => inbound_event && inbound_event.payload,
349
-                                    'metadata' => { 'type' => 'poll',
350
-                                                    'original_hit' => hit_id,
351
-                                                    'answers' => assignments.map(&:answers),
352
-                                                    'event_id' => inbound_event && inbound_event.id }
353
-
354
-              log "Poll HIT created with ID #{poll_hit.id} and URL #{poll_hit.url}.  Original HIT: #{hit_id}", :inbound_event => inbound_event
355
-            else
356
-              if options[:separate_answers]
357
-                payload['answers'].each.with_index do |answer, index|
358
-                  sub_payload = payload.dup
359
-                  sub_payload.delete('answers')
360
-                  sub_payload['answer'] = answer
361
-                  event = create_event :payload => sub_payload
362
-                  log "Event emitted with answer ##{index}", :outbound_event => event, :inbound_event => inbound_event
331
+              if create_poll?
332
+                questions = []
333
+                selections = 5.times.map { |i| { 'key' => i+1, 'text' => i+1 } }.reverse
334
+                assignments.length.times do |index|
335
+                  questions << {
336
+                    'type' => "selection",
337
+                    'name' => "Item #{index + 1}",
338
+                    'key' => index,
339
+                    'required' => "true",
340
+                    'question' => interpolate_string(options['poll_options']['row_template'], assignments[index].answers),
341
+                    'selections' => selections
342
+                  }
363 343
                 end
344
+
345
+                poll_hit = create_hit 'title' => options['poll_options']['title'],
346
+                                      'description' => options['poll_options']['instructions'],
347
+                                      'questions' => questions,
348
+                                      'assignments' => options['poll_options']['assignments'],
349
+                                      'lifetime_in_seconds' => options['poll_options']['lifetime_in_seconds'],
350
+                                      'reward' => options['poll_options']['reward'],
351
+                                      'payload' => inbound_event && inbound_event.payload,
352
+                                      'metadata' => { 'type' => 'poll',
353
+                                                      'original_hit' => hit_id,
354
+                                                      'answers' => assignments.map(&:answers),
355
+                                                      'event_id' => inbound_event && inbound_event.id }
356
+
357
+                log "Poll HIT created with ID #{poll_hit.id} and URL #{poll_hit.url}.  Original HIT: #{hit_id}", :inbound_event => inbound_event
364 358
               else
365
-                event = create_event :payload => payload
366
-                log "Event emitted with answer(s)", :outbound_event => event, :inbound_event => inbound_event
359
+                if options[:separate_answers]
360
+                  payload['answers'].each.with_index do |answer, index|
361
+                    sub_payload = payload.dup
362
+                    sub_payload.delete('answers')
363
+                    sub_payload['answer'] = answer
364
+                    event = create_event :payload => sub_payload
365
+                    log "Event emitted with answer ##{index}", :outbound_event => event, :inbound_event => inbound_event
366
+                  end
367
+                else
368
+                  event = create_event :payload => payload
369
+                  log "Event emitted with answer(s)", :outbound_event => event, :inbound_event => inbound_event
370
+                end
367 371
               end
368 372
             end
369
-          end
370 373
 
371
-          assignments.each(&:approve!)
372
-          hit.dispose!
374
+            assignments.each(&:approve!)
375
+            hit.dispose!
373 376
 
374
-          memory['hits'].delete(hit_id)
377
+            memory['hits'].delete(hit_id)
378
+          end
375 379
         end
376 380
       end
377
-    end
378 381
 
379
-    def all_questions_are_numeric?
380
-      interpolated['hit']['questions'].all? do |question|
381
-        question['selections'].all? do |selection|
382
-          selection['key'] == selection['key'].to_f.to_s || selection['key'] == selection['key'].to_i.to_s
382
+      def all_questions_are_numeric?
383
+        interpolated['hit']['questions'].all? do |question|
384
+          question['selections'].all? do |selection|
385
+            selection['key'] == selection['key'].to_f.to_s || selection['key'] == selection['key'].to_i.to_s
386
+          end
383 387
         end
384 388
       end
385
-    end
386
-
387
-    def create_basic_hit(event = nil)
388
-      hit = create_hit 'title' => options['hit']['title'],
389
-                       'description' => options['hit']['description'],
390
-                       'questions' => options['hit']['questions'],
391
-                       'assignments' => options['hit']['assignments'],
392
-                       'lifetime_in_seconds' => options['hit']['lifetime_in_seconds'],
393
-                       'reward' => options['hit']['reward'],
394
-                       'payload' => event && event.payload,
395
-                       'metadata' => { 'event_id' => event && event.id }
396
-
397
-      log "HIT created with ID #{hit.id} and URL #{hit.url}", :inbound_event => event
398
-    end
399 389
 
400
-    def create_hit(opts = {})
401
-      payload = opts['payload'] || {}
402
-      title = interpolate_string(opts['title'], payload).strip
403
-      description = interpolate_string(opts['description'], payload).strip
404
-      questions = interpolate_options(opts['questions'], payload)
405
-      hit = RTurk::Hit.create(:title => title) do |hit|
406
-        hit.max_assignments = (opts['assignments'] || 1).to_i
407
-        hit.description = description
408
-        hit.lifetime = (opts['lifetime_in_seconds'] || 24 * 60 * 60).to_i
409
-        hit.question_form AgentQuestionForm.new(:title => title, :description => description, :questions => questions)
410
-        hit.reward = (opts['reward'] || 0.05).to_f
411
-        #hit.qualifications.add :approval_rate, { :gt => 80 }
390
+      def create_basic_hit(event = nil)
391
+        hit = create_hit 'title' => options['hit']['title'],
392
+                         'description' => options['hit']['description'],
393
+                         'questions' => options['hit']['questions'],
394
+                         'assignments' => options['hit']['assignments'],
395
+                         'lifetime_in_seconds' => options['hit']['lifetime_in_seconds'],
396
+                         'reward' => options['hit']['reward'],
397
+                         'payload' => event && event.payload,
398
+                         'metadata' => { 'event_id' => event && event.id }
399
+
400
+        log "HIT created with ID #{hit.id} and URL #{hit.url}", :inbound_event => event
412 401
       end
413
-      memory['hits'] ||= {}
414
-      memory['hits'][hit.id] = opts['metadata'] || {}
415
-      hit
416
-    end
417 402
 
418
-    # RTurk Question Form
403
+      def create_hit(opts = {})
404
+        payload = opts['payload'] || {}
405
+        title = interpolate_string(opts['title'], payload).strip
406
+        description = interpolate_string(opts['description'], payload).strip
407
+        questions = interpolate_options(opts['questions'], payload)
408
+        hit = RTurk::Hit.create(:title => title) do |hit|
409
+          hit.max_assignments = (opts['assignments'] || 1).to_i
410
+          hit.description = description
411
+          hit.lifetime = (opts['lifetime_in_seconds'] || 24 * 60 * 60).to_i
412
+          hit.question_form AgentQuestionForm.new(:title => title, :description => description, :questions => questions)
413
+          hit.reward = (opts['reward'] || 0.05).to_f
414
+          #hit.qualifications.add :approval_rate, { :gt => 80 }
415
+        end
416
+        memory['hits'] ||= {}
417
+        memory['hits'][hit.id] = opts['metadata'] || {}
418
+        hit
419
+      end
419 420
 
420
-    class AgentQuestionForm < RTurk::QuestionForm
421
-      needs :title, :description, :questions
421
+      # RTurk Question Form
422 422
 
423
-      def question_form_content
424
-        Overview do
425
-          Title do
426
-            text @title
427
-          end
428
-          Text do
429
-            text @description
430
-          end
431
-        end
423
+      class AgentQuestionForm < RTurk::QuestionForm
424
+        needs :title, :description, :questions
432 425
 
433
-        @questions.each.with_index do |question, index|
434
-          Question do
435
-            QuestionIdentifier do
436
-              text question['key'] || "question_#{index}"
426
+        def question_form_content
427
+          Overview do
428
+            Title do
429
+              text @title
437 430
             end
438
-            DisplayName do
439
-              text question['name'] || "Question ##{index}"
431
+            Text do
432
+              text @description
440 433
             end
441
-            IsRequired do
442
-              text question['required'] || 'true'
443
-            end
444
-            QuestionContent do
445
-              Text do
446
-                text question['question']
434
+          end
435
+
436
+          @questions.each.with_index do |question, index|
437
+            Question do
438
+              QuestionIdentifier do
439
+                text question['key'] || "question_#{index}"
447 440
               end
448
-            end
449
-            AnswerSpecification do
450
-              if question['type'] == "selection"
441
+              DisplayName do
442
+                text question['name'] || "Question ##{index}"
443
+              end
444
+              IsRequired do
445
+                text question['required'] || 'true'
446
+              end
447
+              QuestionContent do
448
+                Text do
449
+                  text question['question']
450
+                end
451
+              end
452
+              AnswerSpecification do
453
+                if question['type'] == "selection"
451 454
 
452
-                SelectionAnswer do
453
-                  StyleSuggestion do
454
-                    text 'radiobutton'
455
-                  end
456
-                  Selections do
457
-                    question['selections'].each do |selection|
458
-                      Selection do
459
-                        SelectionIdentifier do
460
-                          text selection['key']
461
-                        end
462
-                        Text do
463
-                          text selection['text']
455
+                  SelectionAnswer do
456
+                    StyleSuggestion do
457
+                      text 'radiobutton'
458
+                    end
459
+                    Selections do
460
+                      question['selections'].each do |selection|
461
+                        Selection do
462
+                          SelectionIdentifier do
463
+                            text selection['key']
464
+                          end
465
+                          Text do
466
+                            text selection['text']
467
+                          end
464 468
                         end
465 469
                       end
466 470
                     end
467 471
                   end
468
-                end
469 472
 
470
-              else
473
+                else
471 474
 
472
-                FreeTextAnswer do
473
-                  if question['min_length'].present? || question['max_length'].present?
474
-                    Constraints do
475
-                      lengths = {}
476
-                      lengths['minLength'] = question['min_length'].to_s if question['min_length'].present?
477
-                      lengths['maxLength'] = question['max_length'].to_s if question['max_length'].present?
478
-                      Length lengths
475
+                  FreeTextAnswer do
476
+                    if question['min_length'].present? || question['max_length'].present?
477
+                      Constraints do
478
+                        lengths = {}
479
+                        lengths['minLength'] = question['min_length'].to_s if question['min_length'].present?
480
+                        lengths['maxLength'] = question['max_length'].to_s if question['max_length'].present?
481
+                        Length lengths
482
+                      end
479 483
                     end
480
-                  end
481 484
 
482
-                  if question['default'].present?
483
-                    DefaultText do
484
-                      text question['default']
485
+                    if question['default'].present?
486
+                      DefaultText do
487
+                        text question['default']
488
+                      end
485 489
                     end
486 490
                   end
487
-                end
488 491
 
492
+                end
489 493
               end
490 494
             end
491 495
           end

+ 3 - 0
app/models/agents/jabber_agent.rb

@@ -3,7 +3,10 @@ module Agents
3 3
     cannot_be_scheduled!
4 4
     cannot_create_events!
5 5
 
6
+    gem_dependency_check { defined?(Jabber) }
7
+
6 8
     description <<-MD
9
+      #{'## Include `xmpp4r` in your Gemfile to use this Agent!' if dependencies_missing?}
7 10
       The JabberAgent will send any events it receives to your Jabber/XMPP IM account.
8 11
 
9 12
       Specify the `jabber_server` and `jabber_port` for your Jabber server.

+ 3 - 1
app/models/agents/mqtt_agent.rb

@@ -1,10 +1,12 @@
1 1
 # encoding: utf-8 
2
-require "mqtt"
3 2
 require "json"
4 3
 
5 4
 module Agents
6 5
   class MqttAgent < Agent
6
+    gem_dependency_check { defined?(MQTT) }
7
+
7 8
     description <<-MD
9
+      #{'## Include `mqtt` in your Gemfile to use this Agent!' if dependencies_missing?}
8 10
       The MQTT agent allows both publication and subscription to an MQTT topic.
9 11
 
10 12
       MQTT is a generic transport protocol for machine to machine communication.

+ 6 - 2
app/models/agents/slack_agent.rb

@@ -1,11 +1,15 @@
1 1
 module Agents
2 2
   class SlackAgent < Agent
3
+    DEFAULT_WEBHOOK = 'incoming-webhook'
4
+    DEFAULT_USERNAME = 'Huginn'
5
+
3 6
     cannot_be_scheduled!
4 7
     cannot_create_events!
5 8
 
6
-    DEFAULT_WEBHOOK = 'incoming-webhook'
7
-    DEFAULT_USERNAME = 'Huginn'
9
+    gem_dependency_check { defined?(Slack) }
10
+
8 11
     description <<-MD
12
+      #{'## Include `slack-notifier` in your Gemfile to use this Agent!' if dependencies_missing?}
9 13
       The SlackAgent lets you receive events and send notifications to [slack](https://slack.com/).
10 14
 
11 15
       To get started, you will first need to setup an incoming webhook.

+ 13 - 8
app/models/agents/twilio_agent.rb

@@ -1,4 +1,3 @@
1
-require 'twilio-ruby'
2 1
 require 'securerandom'
3 2
 
4 3
 module Agents
@@ -6,7 +5,10 @@ module Agents
6 5
     cannot_be_scheduled!
7 6
     cannot_create_events!
8 7
 
8
+    gem_dependency_check { defined?(Twilio) }
9
+
9 10
     description <<-MD
11
+      #{'## Include `twilio-ruby` in your Gemfile to use this Agent!' if dependencies_missing?}
10 12
       The TwilioAgent receives and collects events and sends them via text message (up to 160 characters) or gives you a call when scheduled.
11 13
 
12 14
       It is assumed that events have a `message`, `text`, or `sms` key, the value of which is sent as the content of the text message/call. You can use the EventFormattingAgent if your event does not provide these keys.
@@ -39,7 +41,6 @@ module Agents
39 41
     end
40 42
 
41 43
     def receive(incoming_events)
42
-      @client = Twilio::REST::Client.new interpolated['account_sid'], interpolated['auth_token']
43 44
       memory['pending_calls'] ||= {}
44 45
       incoming_events.each do |event|
45 46
         message = (event.payload['message'].presence || event.payload['text'].presence || event.payload['sms'].presence).to_s
@@ -63,15 +64,15 @@ module Agents
63 64
     end
64 65
 
65 66
     def send_message(message)
66
-      @client.account.sms.messages.create :from => interpolated['sender_cell'],
67
-                                          :to => interpolated['receiver_cell'],
68
-                                          :body => message
67
+      client.account.sms.messages.create :from => interpolated['sender_cell'],
68
+                                         :to => interpolated['receiver_cell'],
69
+                                         :body => message
69 70
     end
70 71
 
71 72
     def make_call(secret)
72
-      @client.account.calls.create :from => interpolated['sender_cell'],
73
-                                   :to => interpolated['receiver_cell'],
74
-                                   :url => post_url(interpolated['server_url'], secret)
73
+      client.account.calls.create :from => interpolated['sender_cell'],
74
+                                  :to => interpolated['receiver_cell'],
75
+                                  :url => post_url(interpolated['server_url'], secret)
75 76
     end
76 77
 
77 78
     def post_url(server_url, secret)
@@ -85,5 +86,9 @@ module Agents
85 86
         [response.text, 200]
86 87
       end
87 88
     end
89
+
90
+    def client
91
+      @client ||= Twilio::REST::Client.new interpolated['account_sid'], interpolated['auth_token']
92
+    end
88 93
   end
89 94
 end

+ 1 - 2
app/models/agents/twitter_publish_agent.rb

@@ -1,5 +1,3 @@
1
-require "twitter"
2
-
3 1
 module Agents
4 2
   class TwitterPublishAgent < Agent
5 3
     include TwitterConcern
@@ -7,6 +5,7 @@ module Agents
7 5
     cannot_be_scheduled!
8 6
 
9 7
     description <<-MD
8
+      #{twitter_dependencies_missing if dependencies_missing?}
10 9
       The TwitterPublishAgent publishes tweets from the events it receives.
11 10
 
12 11
       To be able to use this Agent you need to authenticate with Twitter in the [Services](/services) section first.

+ 1 - 0
app/models/agents/twitter_stream_agent.rb

@@ -5,6 +5,7 @@ module Agents
5 5
     cannot_receive_events!
6 6
 
7 7
     description <<-MD
8
+      #{twitter_dependencies_missing if dependencies_missing?}
8 9
       The TwitterStreamAgent follows the Twitter stream in real time, watching for certain keywords, or filters, that you provide.
9 10
 
10 11
       To follow the Twitter stream, provide an array of `filters`.  Multiple words in a filter must all show up in a tweet, but are independent of order.

+ 1 - 2
app/models/agents/twitter_user_agent.rb

@@ -1,5 +1,3 @@
1
-require "twitter"
2
-
3 1
 module Agents
4 2
   class TwitterUserAgent < Agent
5 3
     include TwitterConcern
@@ -7,6 +5,7 @@ module Agents
7 5
     cannot_receive_events!
8 6
 
9 7
     description <<-MD
8
+      #{twitter_dependencies_missing if dependencies_missing?}
10 9
       The TwitterUserAgent follows the timeline of a specified Twitter user.
11 10
 
12 11
       To be able to use this Agent you need to authenticate with Twitter in the [Services](/services) section first.

+ 4 - 2
app/models/agents/user_location_agent.rb

@@ -65,8 +65,10 @@ module Agents
65 65
     private
66 66
 
67 67
     def handle_payload(payload)
68
-      if payload[:latitude].present? && payload[:longitude].present?
69
-        create_event payload: payload, lat: payload[:latitude].to_f, lng: payload[:longitude].to_f
68
+      location = Location.new(payload)
69
+
70
+      if location.present?
71
+        create_event payload: payload, location: location
70 72
       end
71 73
     end
72 74
   end

+ 4 - 1
app/models/agents/weather_agent.rb

@@ -5,7 +5,10 @@ module Agents
5 5
   class WeatherAgent < Agent
6 6
     cannot_receive_events!
7 7
 
8
+    gem_dependency_check { defined?(Wunderground) && defined?(ForecastIO) }
9
+
8 10
     description <<-MD
11
+      #{'## Include `forecast_io` and `wunderground` in your Gemfile to use this Agent!' if dependencies_missing?}
9 12
       The WeatherAgent creates an event for the day's weather at a given `location`.
10 13
 
11 14
       You also must select `which_day` you would like to get the weather for where the number 0 is for today and 1 is for tomorrow and so on. Weather is only returned for 1 week at a time.
@@ -14,7 +17,7 @@ module Agents
14 17
 
15 18
       The `location` can be a US zipcode, or any location that Wunderground supports. To find one, search [wunderground.com](http://wunderground.com) and copy the location part of the URL.  For example, a result for San Francisco gives `http://www.wunderground.com/US/CA/San_Francisco.html` and London, England gives `http://www.wunderground.com/q/zmw:00000.1.03772`.  The locations in each are `US/CA/San_Francisco` and `zmw:00000.1.03772`, respectively.
16 19
 
17
-      If you plan on using ForecastIO, the `location` must be a set of GPS coordinates.
20
+      If you plan on using ForecastIO, the `location` must be a comma-separated string of co-ordinates (longitude, latitude). For example, San Francisco would be `37.7771,-122.4196`.
18 21
 
19 22
       You must setup an [API key for Wunderground](http://www.wunderground.com/weather/api/) in order to use this Agent with Wunderground.
20 23
 

+ 2 - 3
app/models/agents/weibo_publish_agent.rb

@@ -1,5 +1,4 @@
1 1
 # encoding: utf-8 
2
-require "weibo_2"
3 2
 
4 3
 module Agents
5 4
   class WeiboPublishAgent < Agent
@@ -8,6 +7,7 @@ module Agents
8 7
     cannot_be_scheduled!
9 8
 
10 9
     description <<-MD
10
+      #{'## Include `weibo_2` in your Gemfile to use this Agent!' if dependencies_missing?}
11 11
       The WeiboPublishAgent publishes tweets from the events it receives.
12 12
 
13 13
       You must first set up a Weibo app and generate an `acess_token` for the user to send statuses as.
@@ -79,8 +79,7 @@ module Agents
79 79
       tweet_json[:entities][:urls].each do |url|
80 80
         text.gsub! url[:url], url[:expanded_url]
81 81
       end
82
-      return text
82
+      text
83 83
     end
84
-
85 84
   end
86 85
 end

+ 1 - 1
app/models/agents/weibo_user_agent.rb

@@ -1,5 +1,4 @@
1 1
 # encoding: utf-8 
2
-require "weibo_2"
3 2
 
4 3
 module Agents
5 4
   class WeiboUserAgent < Agent
@@ -8,6 +7,7 @@ module Agents
8 7
     cannot_receive_events!
9 8
 
10 9
     description <<-MD
10
+      #{'## Include `weibo_2` in your Gemfile to use this Agent!' if dependencies_missing?}
11 11
       The WeiboUserAgent follows the timeline of a specified Weibo user. It uses this endpoint: http://open.weibo.com/wiki/2/statuses/user_timeline/en
12 12
 
13 13
       You must first set up a Weibo app and generate an `acess_token` to authenticate with. Provide that, along with the `app_key` and `app_secret` for your Weibo app in the options.

+ 43 - 1
app/models/event.rb

@@ -1,3 +1,5 @@
1
+require 'location'
2
+
1 3
 # Events are how Huginn Agents communicate and log information about the world.  Events can be emitted and received by
2 4
 # Agents.  They contain a serialized `payload` of arbitrary JSON data, as well as optional `lat`, `lng`, and `expires_at`
3 5
 # fields.
@@ -5,7 +7,7 @@ class Event < ActiveRecord::Base
5 7
   include JSONSerializedField
6 8
   include LiquidDroppable
7 9
 
8
-  attr_accessible :lat, :lng, :payload, :user_id, :user, :expires_at
10
+  attr_accessible :lat, :lng, :location, :payload, :user_id, :user, :expires_at
9 11
 
10 12
   acts_as_mappable
11 13
 
@@ -28,6 +30,42 @@ class Event < ActiveRecord::Base
28 30
     where("expires_at IS NOT NULL AND expires_at < ?", Time.now)
29 31
   }
30 32
 
33
+  scope :with_location, -> {
34
+    where.not(lat: nil).where.not(lng: nil)
35
+  }
36
+
37
+  def location
38
+    @location ||= Location.new(
39
+      # lat and lng are BigDecimal, but converted to Float by the Location class
40
+      lat: lat,
41
+      lng: lng,
42
+      radius:
43
+        begin
44
+          h = payload[:horizontal_accuracy].presence
45
+          v = payload[:vertical_accuracy].presence
46
+          if h && v
47
+            (h.to_f + v.to_f) / 2
48
+          else
49
+            (h || v || payload[:accuracy]).to_f
50
+          end
51
+        end,
52
+      course: payload[:course],
53
+      speed: payload[:speed].presence)
54
+  end
55
+
56
+  def location=(location)
57
+    case location
58
+    when nil
59
+      self.lat = self.lng = nil
60
+      return
61
+    when Location
62
+    else
63
+      location = Location.new(location)
64
+    end
65
+    self.lat, self.lng = location.lat, location.lng
66
+    location
67
+  end
68
+
31 69
   # Emit this event again, as a new Event.
32 70
   def reemit!
33 71
     agent.create_event :payload => payload, :lat => lat, :lng => lng
@@ -79,4 +117,8 @@ class EventDrop
79 117
       @object.created_at
80 118
     }
81 119
   end
120
+
121
+  def _location_
122
+    @object.location
123
+  end
82 124
 end

+ 74 - 70
app/views/agents/_form.html.erb

@@ -1,5 +1,5 @@
1 1
 <% if @agent.errors.any? %>
2
-  <div class="row well">
2
+  <div class="row well model-errors">
3 3
     <h2><%= pluralize(@agent.errors.count, "error") %> prohibited this Agent from being saved:</h2>
4 4
     <% @agent.errors.full_messages.each do |msg| %>
5 5
       <p class='text-warning'><%= msg %></p>
@@ -21,99 +21,103 @@
21 21
           <% if @agent.new_record? %>
22 22
             <div class="form-group type-select">
23 23
               <%= f.label :type %>
24
-              <%= f.select :type, options_for_select(Agent.types.map(&:to_s).sort.map {|type| [type.gsub(/^.*::/, ''), type] }, @agent.type), {}, :class => 'select2 form-control' %>
24
+              <%= f.select :type, options_for_select([['Select an Agent Type', 'Agent']] + Agent.types.map(&:to_s).sort.map {|type| [type.gsub(/^.*::/, ''), type] }, @agent.type), {}, :class => 'select2 form-control' %>
25 25
             </div>
26 26
           <% end %>
27
+        </div>
27 28
 
28
-          <div class="form-group type-select">
29
-            <%= f.label :name %>
30
-            <%= f.text_field :name, :class => 'form-control' %>
31
-          </div>
32
-
33
-          <div class='oauthable-form'>
34
-            <%= render partial: 'oauth_dropdown' %>
35
-          </div>
29
+        <div class="agent-settings">
30
+          <div class="col-md-8">
31
+            <div class="form-group">
32
+              <%= f.label :name %>
33
+              <%= f.text_field :name, :class => 'form-control' %>
34
+            </div>
36 35
 
37
-          <div class="form-group">
38
-            <%= f.label :schedule, :class => 'control-label' %>
39
-            <div class="schedule-region" data-can-be-scheduled="<%= @agent.can_be_scheduled? %>">
40
-              <div class="can-be-scheduled">
41
-                <%= f.select :schedule, options_for_select(Agent::SCHEDULES.map {|s| [s.humanize.titleize, s] }, @agent.schedule), {}, :class => 'form-control' %>
42
-              </div>
43
-              <span class='cannot-be-scheduled text-info'>This type of Agent cannot be scheduled.</span>
36
+            <div class='oauthable-form'>
37
+              <%= render partial: 'oauth_dropdown', locals: { agent: @agent } %>
44 38
             </div>
45
-          </div>
46 39
 
47
-          <div class="controller-region" data-has-controllers="<%= !@agent.controllers.empty? %>">
48 40
             <div class="form-group">
49
-              <%= f.label :controllers %>
50
-              <span class="glyphicon glyphicon-question-sign hover-help" data-content="Other than the system-defined schedule above, this agent may be run or controlled by these user-defined Agents."></span>
51
-              <div class="controller-list">
52
-                <%= agent_controllers(@agent) || 'None' %>
41
+              <%= f.label :schedule, :class => 'control-label' %>
42
+              <div class="schedule-region" data-can-be-scheduled="<%= @agent.can_be_scheduled? %>">
43
+                <div class="can-be-scheduled">
44
+                  <%= f.select :schedule, options_for_select(Agent::SCHEDULES.map {|s| [s.humanize.titleize, s] }, @agent.schedule), {}, :class => 'form-control' %>
45
+                </div>
46
+                <span class='cannot-be-scheduled text-info'>This type of Agent cannot be scheduled.</span>
53 47
               </div>
54 48
             </div>
55
-          </div>
56 49
 
57
-          <div class="control-link-region" data-can-control-other-agents="<%= @agent.can_control_other_agents? %>">
58
-            <div class="can-control-other-agents">
50
+            <div class="controller-region" data-has-controllers="<%= !@agent.controllers.empty? %>">
59 51
               <div class="form-group">
60
-                <%= f.label :control_targets %>
61
-                <% eventControlTargets = current_user.agents.select(&:can_be_scheduled?) %>
62
-                <%= f.select(:control_target_ids,
63
-                             options_for_select(eventControlTargets.map {|s| [s.name, s.id] },
64
-                                                @agent.control_target_ids),
65
-                             {}, { multiple: true, size: 5, class: 'select2 form-control' }) %>
52
+                <%= f.label :controllers %>
53
+                <span class="glyphicon glyphicon-question-sign hover-help" data-content="Other than the system-defined schedule above, this agent may be run or controlled by these user-defined Agents."></span>
54
+                <div class="controller-list">
55
+                  <%= agent_controllers(@agent) || 'None' %>
56
+                </div>
66 57
               </div>
67 58
             </div>
68
-          </div>
69 59
 
70
-          <div class='event-related-region' data-can-create-events="<%= @agent.can_create_events? %>">
71
-            <div class="form-group">
72
-              <%= f.label :keep_events_for, "Keep events" %>
73
-              <span class="glyphicon glyphicon-question-sign hover-help" data-content="In order to conserve disk space, you can choose to have events created by this Agent expire after a certain period of time.  Make sure you keep them long enough to allow any subsequent Agents to make use of them."></span>
74
-              <%= f.select :keep_events_for, options_for_select(Agent::EVENT_RETENTION_SCHEDULES, @agent.keep_events_for), {}, :class => 'form-control' %>
60
+            <div class="control-link-region" data-can-control-other-agents="<%= @agent.can_control_other_agents? %>">
61
+              <div class="can-control-other-agents">
62
+                <div class="form-group">
63
+                  <%= f.label :control_targets %>
64
+                  <% eventControlTargets = current_user.agents.select(&:can_be_scheduled?) %>
65
+                  <%= f.select(:control_target_ids,
66
+                               options_for_select(eventControlTargets.map {|s| [s.name, s.id] },
67
+                                                  @agent.control_target_ids),
68
+                               {}, { multiple: true, size: 5, class: 'select2 form-control' }) %>
69
+                </div>
70
+              </div>
75 71
             </div>
76
-          </div>
77 72
 
78
-          <div class="form-group">
79
-            <%= f.label :sources %>
80
-            <div class="link-region" data-can-receive-events="<%= @agent.can_receive_events? %>">
81
-              <% eventSources = (current_user.agents - [@agent]).find_all { |a| a.can_create_events? } %>
82
-              <%= f.select(:source_ids,
83
-                           options_for_select(eventSources.map {|s| [s.name, s.id] },
84
-                                              @agent.source_ids),
85
-                           {}, { :multiple => true, :size => 5, :class => 'select2 form-control' }) %>
86
-              <span class='cannot-receive-events text-info'>This type of Agent cannot receive events.</span>
87
-              <%= f.label :propagate_immediately, :class => 'propagate-immediately' do %>Propagate immediately
88
-                <%= f.check_box :propagate_immediately %> 
89
-              <% end %>
73
+            <div class='event-related-region' data-can-create-events="<%= @agent.can_create_events? %>">
74
+              <div class="form-group">
75
+                <%= f.label :keep_events_for, "Keep events" %>
76
+                <span class="glyphicon glyphicon-question-sign hover-help" data-content="In order to conserve disk space, you can choose to have events created by this Agent expire after a certain period of time.  Make sure you keep them long enough to allow any subsequent Agents to make use of them."></span>
77
+                <%= f.select :keep_events_for, options_for_select(Agent::EVENT_RETENTION_SCHEDULES, @agent.keep_events_for), {}, :class => 'form-control' %>
78
+              </div>
90 79
             </div>
91
-          </div>
92 80
 
93
-          <% if current_user.scenario_count > 0 %>
94 81
             <div class="form-group">
95
-              <%= f.label :scenarios %>
96
-              <span class="glyphicon glyphicon-question-sign hover-help" data-content="Use Scenarios to group sets of Agents, both for organization, and to make them easy to export and share."></span>
97
-              <%= f.select(:scenario_ids,
98
-                           options_for_select(current_user.scenarios.pluck(:name, :id), @agent.scenario_ids),
99
-                           {}, { :multiple => true, :size => 5, :class => 'select2 form-control' }) %>
82
+              <%= f.label :sources %>
83
+              <div class="link-region" data-can-receive-events="<%= @agent.can_receive_events? %>">
84
+                <% eventSources = (current_user.agents - [@agent]).find_all { |a| a.can_create_events? } %>
85
+                <%= f.select(:source_ids,
86
+                             options_for_select(eventSources.map {|s| [s.name, s.id] },
87
+                                                @agent.source_ids),
88
+                             {}, { :multiple => true, :size => 5, :class => 'select2 form-control' }) %>
89
+                <span class='cannot-receive-events text-info'>This type of Agent cannot receive events.</span>
90
+                <%= f.label :propagate_immediately, :class => 'propagate-immediately' do %>Propagate immediately
91
+                  <%= f.check_box :propagate_immediately %>
92
+                <% end %>
93
+              </div>
100 94
             </div>
101
-          <% end %>
102 95
 
103
-        </div>
96
+            <% if current_user.scenario_count > 0 %>
97
+              <div class="form-group">
98
+                <%= f.label :scenarios %>
99
+                <span class="glyphicon glyphicon-question-sign hover-help" data-content="Use Scenarios to group sets of Agents, both for organization, and to make them easy to export and share."></span>
100
+                <%= f.select(:scenario_ids,
101
+                             options_for_select(current_user.scenarios.pluck(:name, :id), @agent.scenario_ids),
102
+                             {}, { :multiple => true, :size => 5, :class => 'select2 form-control' }) %>
103
+              </div>
104
+            <% end %>
104 105
 
105
-        <!-- Form controls full width -->
106
-        <div class="col-md-12">
107
-          <div class="form-group">
108
-            <%= f.label :options %>
109
-            <span class="glyphicon glyphicon-question-sign hover-help" data-content="In this JSON hash, interpolation is available in almost all values using the Liquid templating language.<p>Available template variables include the following:<dl><dt><code>message</code>, <code>url</code>, etc.</dt><dd>Refers to the corresponding key's value of each incoming event's payload.</dd><dt><code>agent</code></dt><dd>Refers to the agent that created each incoming event.  It has attributes like <code>type</code>, <code>name</code> and <code>options</code>, so <code>{{agent.type}}</code> will expand to <code>WebsiteAgent</code> if an incoming event is created by that agent.</dd></dl></p><p>To access user credentials, use the <code>credential</code> tag like this: <code>{% credential <em>bare_key_name</em> %}</code></p>"></span>
110
-            <textarea rows="15" id="agent_options" name="agent[options]" class="form-control live-json-editor <%= (@agent.new_record? && @agent.options == {}) ? "showing-default" : "" %>">
111
-              <%= Utils.jsonify((@agent.new_record? && @agent.options == {}) ? @agent.default_options : @agent.options) %>
112
-            </textarea>
113 106
           </div>
114 107
 
115
-          <div class="form-group">
116
-            <%= f.submit "Save", :class => "btn btn-primary" %>
108
+          <!-- Form controls full width -->
109
+          <div class="col-md-12">
110
+            <div class="form-group">
111
+              <%= f.label :options %>
112
+              <span class="glyphicon glyphicon-question-sign hover-help" data-content="In this JSON hash, interpolation is available in almost all values using the Liquid templating language.<p>Available template variables include the following:<dl><dt><code>message</code>, <code>url</code>, etc.</dt><dd>Refers to the corresponding key's value of each incoming event's payload.</dd><dt><code>agent</code></dt><dd>Refers to the agent that created each incoming event.  It has attributes like <code>type</code>, <code>name</code> and <code>options</code>, so <code>{{agent.type}}</code> will expand to <code>WebsiteAgent</code> if an incoming event is created by that agent.</dd></dl></p><p>To access user credentials, use the <code>credential</code> tag like this: <code>{% credential <em>bare_key_name</em> %}</code></p>"></span>
113
+              <textarea rows="15" id="agent_options" name="agent[options]" class="form-control live-json-editor">
114
+                <%= Utils.jsonify((@agent.new_record? && @agent.options == {}) ? @agent.default_options : @agent.options) %>
115
+              </textarea>
116
+            </div>
117
+
118
+            <div class="form-group">
119
+              <%= f.submit "Save", :class => "btn btn-primary" %>
120
+            </div>
117 121
           </div>
118 122
         </div>
119 123
       </div>

+ 2 - 2
app/views/agents/_oauth_dropdown.html.erb

@@ -1,6 +1,6 @@
1
-<% if @agent.try(:oauthable?) %>
1
+<% if agent.try(:oauthable?) %>
2 2
   <div class="form-group type-select">
3 3
     <%= label_tag :service %>
4
-    <%= select_tag 'agent[service_id]', options_for_select(@agent.valid_services_for(current_user).collect { |s| ["(#{s.provider}) #{s.name}", s.id]}, @agent.service_id), class: 'form-control' %>
4
+    <%= select_tag 'agent[service_id]', options_for_select(agent.valid_services_for(current_user).collect { |s| ["(#{s.provider}) #{s.name}", s.id]}, agent.service_id), class: 'form-control' %>
5 5
   </div>
6 6
 <% end %>

+ 6 - 6
app/views/agents/_table.html.erb

@@ -13,7 +13,7 @@
13 13
 
14 14
     <% @agents.each do |agent| %>
15 15
       <tr>
16
-        <td class='<%= "agent-disabled" if agent.disabled? %>'>
16
+        <td class='<%= "agent-unavailable" if agent.unavailable? %>'>
17 17
           <%= link_to agent.name, agent_path(agent) %>
18 18
           <br/>
19 19
           <span class='text-muted'><%= agent.short_type.titleize %></span>
@@ -23,35 +23,35 @@
23 23
             </span>
24 24
           <% end %>
25 25
         </td>
26
-        <td class='<%= "agent-disabled" if agent.disabled? %>'>
26
+        <td class='<%= "agent-unavailable" if agent.unavailable? %>'>
27 27
           <% if agent.can_be_scheduled? %>
28 28
             <%= agent_schedule(agent, ',<br/>') %>
29 29
           <% else %>
30 30
             <span class='not-applicable'></span>
31 31
           <% end %>
32 32
         </td>
33
-        <td class='<%= "agent-disabled" if agent.disabled? %>'>
33
+        <td class='<%= "agent-unavailable" if agent.unavailable? %>'>
34 34
           <% if agent.can_be_scheduled? %>
35 35
             <%= agent.last_check_at ? time_ago_in_words(agent.last_check_at) + " ago" : "never" %>
36 36
           <% else %>
37 37
             <span class='not-applicable'></span>
38 38
           <% end %>
39 39
         </td>
40
-        <td class='<%= "agent-disabled" if agent.disabled? %>'>
40
+        <td class='<%= "agent-unavailable" if agent.unavailable? %>'>
41 41
           <% if agent.can_create_events? %>
42 42
             <%= agent.last_event_at ? time_ago_in_words(agent.last_event_at) + " ago" : "never" %>
43 43
           <% else %>
44 44
             <span class='not-applicable'></span>
45 45
           <% end %>
46 46
         </td>
47
-        <td class='<%= "agent-disabled" if agent.disabled? %>'>
47
+        <td class='<%= "agent-unavailable" if agent.unavailable? %>'>
48 48
           <% if agent.can_receive_events? %>
49 49
             <%= agent.last_receive_at ? time_ago_in_words(agent.last_receive_at) + " ago" : "never" %>
50 50
           <% else %>
51 51
             <span class='not-applicable'></span>
52 52
           <% end %>
53 53
         </td>
54
-        <td class='<%= "agent-disabled" if agent.disabled? %>'>
54
+        <td class='<%= "agent-unavailable" if agent.unavailable? %>'>
55 55
           <% if agent.can_create_events? %>
56 56
             <%= link_to(agent.events_count || 0, agent_events_path(agent)) %>
57 57
           <% else %>

+ 8 - 6
app/views/agents/agent_views/user_location_agent/_show.html.erb

@@ -1,8 +1,11 @@
1
-<script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?sensor=false"></script>
1
+<% content_for :head do -%>
2
+<%= javascript_include_tag "https://maps.googleapis.com/maps/api/js?sensor=false" %>
3
+<%= javascript_include_tag "map_marker" %>
4
+<% end -%>
2 5
 
3 6
 <h3>Recent Event Map</h3>
4 7
 
5
-<% events = @agent.events.where("lat IS NOT null AND lng IS NOT null").order("id desc").limit(500) %>
8
+<% events = @agent.events.with_location.order("id desc").limit(500) %>
6 9
 <% if events.length > 0 %>
7 10
   <div id="map_canvas" style="width:800px; height:800px"></div>
8 11
 
@@ -14,11 +17,10 @@
14 17
     };
15 18
 
16 19
     var map = new google.maps.Map(document.getElementById("map_canvas"), mapOptions);
20
+    <% events.each do |event| %>
21
+    map_marker(map, <%= Utils.jsonify(event.location) %>);
22
+    <% end %>
17 23
   </script>
18
-
19
-  <% events.each do |event| %>
20
-    <%= render "shared/map_marker", event: event %>
21
-  <% end %>
22 24
 <% else %>
23 25
   <p>
24 26
     No events found.

+ 3 - 3
app/views/devise/registrations/new.html.erb

@@ -15,7 +15,7 @@
15 15
             <ul>
16 16
               <li>Read <a href="https://github.com/cantino/huginn/wiki/Run-Huginn-for-free-on-Heroku" target="_target">this document</a> carefully if you are going to try out Huginn for free on <a href="https://id.heroku.com/" target="_target">Heroku</a>.</li>
17 17
 
18
-              <li>Install the <a href="https://toolbelt.heroku.com/" target="_target">Heroku Toolbelt</a> and run <kbd>heroku login</kbd> if you haven't already.</li>
18
+              <li>Install the <a href="https://toolbelt.heroku.com/" target="_target">Heroku Toolbelt</a> and run <kbd>heroku login</kbd>, if you haven't already.</li>
19 19
 
20 20
               <li>Run the following commands:<br />
21 21
                 <%= content_tag :pre do -%>
@@ -25,7 +25,7 @@ bundle
25 25
 bin/setup_heroku
26 26
 <%- end %>
27 27
 
28
-              <li>Get back to this page and sign up with the invitation code shown by the last command.</li>
28
+              <li>This command will create an admin account for you.</li>
29 29
             </ul>
30 30
           </div>
31 31
           <% end %>
@@ -78,4 +78,4 @@ bin/setup_heroku
78 78
       </div>
79 79
     </div>
80 80
   </div>
81
-</div>
81
+</div>

+ 6 - 3
app/views/events/show.html.erb

@@ -16,7 +16,10 @@
16 16
       </p>
17 17
 
18 18
       <% if @event.lat && @event.lng %>
19
-        <script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?sensor=false"></script>
19
+        <% content_for :head do -%>
20
+<%= javascript_include_tag "https://maps.googleapis.com/maps/api/js?sensor=false" %>
21
+<%= javascript_include_tag "map_marker" %>
22
+        <% end -%>
20 23
 
21 24
         <p>
22 25
           <b>Lat:</b>
@@ -36,9 +39,9 @@
36 39
           };
37 40
 
38 41
           var map = new google.maps.Map(document.getElementById("map_canvas"), mapOptions);
39
-        </script>
40 42
 
41
-        <%= render "shared/map_marker", event: @event %>
43
+          map_marker(map, <%= Utils.jsonify(@event.location) %>);
44
+        </script>
42 45
       <% end %>
43 46
 
44 47
       <br />

+ 5 - 5
app/views/layouts/_navigation.html.erb

@@ -28,7 +28,7 @@
28 28
   
29 29
   <ul class="nav navbar-nav navbar-right">
30 30
     <% if user_signed_in? %>
31
-      <form class="navbar-form navbar-left" role="search">
31
+      <form class="navbar-form navbar-left visible-lg" role="search">
32 32
         <div class="form-group">
33 33
           <input type="text" class="form-control" id='agent-navigate' autocomplete="off" placeholder="Search">
34 34
           <%= image_tag "spinner-arrows.gif", :class => "spinner" %>
@@ -36,22 +36,22 @@
36 36
       </form>
37 37
       
38 38
       <li class='job-indicator' role='pending'>
39
-        <%= link_to current_user.admin? ? jobs_path : '#' do %>
39
+        <%= link_to current_user.admin? ? jobs_path : '#', class: 'visible-lg' do %>
40 40
           <span class="badge"><span class="glyphicon glyphicon-refresh icon-white"></span> <span class='number'>0</span></span>
41 41
         <% end %>
42 42
       </li>
43 43
       <li class='job-indicator' role='awaiting_retry'>
44
-        <%= link_to current_user.admin? ? jobs_path : '#' do %>
44
+        <%= link_to current_user.admin? ? jobs_path : '#', class: 'visible-lg' do %>
45 45
           <span class="badge"><span class="glyphicon glyphicon-question-sign icon-yellow"></span> <span class='number'>0</span></span>
46 46
         <% end %>
47 47
       </li>
48 48
       <li class='job-indicator' role='recent_failures'>
49
-        <%= link_to current_user.admin? ? jobs_path : '#' do %>
49
+        <%= link_to current_user.admin? ? jobs_path : '#', class: 'hidden-sm hidden-xs' do %>
50 50
           <span class="badge"><span class="glyphicon glyphicon-exclamation-sign icon-white"></span> <span class='number'>0</span></span>
51 51
         <% end %>
52 52
       </li>
53 53
       <li id='event-indicator'>
54
-        <a href="#">
54
+        <a href="#" class='hidden-sm hidden-xs'>
55 55
           <span class="badge"><span class="glyphicon glyphicon-random icon-white"></span> <span class='number'>0</span> new events</span>
56 56
         </a>
57 57
       </li>

+ 11 - 12
app/views/layouts/application.html.erb

@@ -35,22 +35,21 @@
35 35
     </div>
36 36
 
37 37
     <script>
38
-      var agentPaths = {};
39
-      var agentNames = [];
38
+      window.agentPaths = {};
39
+      window.agentNames = [];
40 40
       <% if current_user.present? -%>
41 41
         var myAgents = <%= Utils.jsonify(current_user.agents.pluck(:name, :id).inject({}) {|m, a| m[a.first] = agent_path(a.last); m }) %>;
42 42
         var myScenarios = <%= Utils.jsonify(current_user.scenarios.pluck(:name, :id).inject({}) {|m, s| m[s.first + " Scenario"] = scenario_path(s.last); m }) %>;
43
-        $.extend(agentPaths, myAgents);
44
-        $.extend(agentPaths, myScenarios);
45
-        agentPaths["All Agents Index"] = <%= Utils.jsonify agents_path %>;
46
-        agentPaths["New Agent"] = <%= Utils.jsonify new_agent_path %>;
47
-        agentPaths["Account"] = <%= Utils.jsonify edit_user_registration_path %>;
48
-        agentPaths["Events Index"] = <%= Utils.jsonify events_path %>;
49
-        agentPaths["View Agent Diagram"] = <%= Utils.jsonify diagram_path %>;
50
-        agentPaths["Run Event Propagation"] = { url: <%= Utils.jsonify propagate_agents_path %>, method: 'POST' };
43
+        $.extend(window.agentPaths, myAgents);
44
+        $.extend(window.agentPaths, myScenarios);
45
+        window.agentPaths["All Agents Index"] = <%= Utils.jsonify agents_path %>;
46
+        window.agentPaths["New Agent"] = <%= Utils.jsonify new_agent_path %>;
47
+        window.agentPaths["Account"] = <%= Utils.jsonify edit_user_registration_path %>;
48
+        window.agentPaths["Events Index"] = <%= Utils.jsonify events_path %>;
49
+        window.agentPaths["View Agent Diagram"] = <%= Utils.jsonify diagram_path %>;
50
+        window.agentPaths["Run Event Propagation"] = { url: <%= Utils.jsonify propagate_agents_path %>, method: 'POST' };
51 51
 
52
-
53
-        $.each(agentPaths, function(name, v) { agentNames.push(name); });
52
+        $.each(window.agentPaths, function(name, v) { window.agentNames.push(name); });
54 53
       <% end -%>
55 54
     </script>
56 55
   </body>

+ 6 - 6
app/views/services/index.html.erb

@@ -11,14 +11,14 @@
11 11
         <%= link_to 'wiki', 'https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications', target: :_blank %>
12 12
         for guidance.
13 13
       </p>
14
-      <% if has_oauth_configuration_for('twitter') %>
15
-        <p><%= link_to "Authenticate with Twitter", "/auth/twitter" %></p>
14
+      <% if has_oauth_configuration_for?('twitter') %>
15
+        <p><%= link_to "/auth/twitter", class: 'btn btn-default btn-auth btn-auth-twitter' do %><i class='fa fa-twitter'></i><span>Authenticate with Twitter</span><% end %></p>
16 16
       <% end %>
17
-      <% if has_oauth_configuration_for('thirty_seven_signals') %>
18
-        <p><%= link_to "Authenticate with 37Signals (Basecamp)", "/auth/37signals" %></p>
17
+      <% if has_oauth_configuration_for?('37signals') %>
18
+        <p><%= link_to "/auth/37signals", class: 'btn btn-default btn-auth btn-auth-37signals' do %><i class='fa fa-lock'></i><span>Authenticate with 37Signals (Basecamp)</span><% end %></p>
19 19
       <% end -%>
20
-      <% if has_oauth_configuration_for('github') %>
21
-        <p><%= link_to "Authenticate with Github", "/auth/github" %></p>
20
+      <% if has_oauth_configuration_for?('github') %>
21
+        <p><%= link_to "/auth/github", class: 'btn btn-default btn-auth btn-auth-github' do %><i class='fa fa-github'></i><span>Authenticate with Github</span><% end %></p>
22 22
       <% end -%>
23 23
       <% if has_oauth_configuration_for('tumblr') %>
24 24
         <p><%= link_to "Authenticate with Tumblr", "/auth/tumblr" %></p>

+ 0 - 61
app/views/shared/_map_marker.html.erb

@@ -1,61 +0,0 @@
1
-<script>
2
-  (function(map) {
3
-    <%
4
-       if event.payload[:horizontal_accuracy] && event.payload[:vertical_accuracy]
5
-         radius = (event.payload[:horizontal_accuracy].to_f + event.payload[:vertical_accuracy].to_f) / 2.0
6
-       elsif event.payload[:horizontal_accuracy]
7
-         radius = event.payload[:horizontal_accuracy].to_f
8
-       elsif event.payload[:vertical_accuracy]
9
-         radius = event.payload[:vertical_accuracy].to_f
10
-       elsif event.payload[:accuracy]
11
-         radius = event.payload[:accuracy].to_f
12
-       else
13
-         radius = 0
14
-       end
15
-    %>
16
-
17
-    var pos = new google.maps.LatLng(<%= event.lat %>, <%= event.lng %>);
18
-
19
-    <% if radius > 0 %>
20
-      var accuracyCircle = new google.maps.Circle({
21
-        strokeColor: '#FF0000',
22
-        strokeOpacity: 0.8,
23
-        strokeWeight: 2,
24
-        fillColor: '#FF0000',
25
-        fillOpacity: 0.35,
26
-        map: map,
27
-        center: pos,
28
-        radius: <%= radius %>
29
-      });
30
-    <% else %>
31
-      var marker = new google.maps.Marker({
32
-        position: pos,
33
-        map: map,
34
-        title: 'Recorded Location'
35
-      });
36
-    <% end %>
37
-
38
-
39
-    <% if event.payload[:course] && event.payload[:course].to_f > -1 %>
40
-      var p1 = new LatLon(pos.lat(), pos.lng());
41
-      var p2 = p1.destinationPoint(<%= event.payload[:course].to_f %>, <%= [0.2, (event.payload[:speed] || 1).to_f].max * 0.1 %>);
42
-
43
-      var lineCoordinates = [ pos, new google.maps.LatLng(p2.lat(), p2.lon()) ];
44
-
45
-      var lineSymbol = {
46
-        path:google.maps.SymbolPath.FORWARD_CLOSED_ARROW
47
-      };
48
-
49
-      var line = new google.maps.Polyline({
50
-        path: lineCoordinates,
51
-        icons: [
52
-          {
53
-            icon: lineSymbol,
54
-            offset: '100%'
55
-          }
56
-        ],
57
-        map: map
58
-      });
59
-    <% end %>
60
-  })(map);
61
-</script>

+ 3 - 2
app/views/user_credentials/index.html.erb

@@ -37,8 +37,9 @@
37 37
       <br/>
38 38
 
39 39
       <div class="btn-group">
40
-        <%= link_to '<span class="glyphicon glyphicon-plus"></span> New Credential'.html_safe, new_user_credential_path, class: "btn btn-default" %>
40
+        <%= link_to new_user_credential_path, class: "btn btn-default" do %><span class="glyphicon glyphicon-plus"></span> New Credential<% end %>
41
+        <%= link_to user_credentials_path(format: :json), class: "btn btn-default" do %><span class="glyphicon glyphicon-download-alt"></span> Download Credentials<% end %>
41 42
       </div>
42 43
     </div>
43 44
   </div>
44
-</div>
45
+</div>

+ 29 - 19
bin/setup_heroku

@@ -138,19 +138,6 @@ unless $config['SMTP_DOMAIN'] && $config['SMTP_USER_NAME'] && $config['SMTP_PASS
138 138
   end
139 139
 end
140 140
 
141
-if first_time
142
-  puts "Restarting..."
143
-  puts capture("heroku restart")
144
-
145
-  puts "Done!"
146
-  puts
147
-  puts "Visit https://#{app_name}.herokuapp.com/users/sign_up and use the invitation code shown below:"
148
-  puts
149
-  puts "\t#{$config['INVITATION_CODE']}"
150
-
151
-  exit
152
-end
153
-
154 141
 branch = capture("git rev-parse --abbrev-ref HEAD")
155 142
 if yes?("Should I push your current branch (#{branch}) to heroku?")
156 143
   puts "This may take a moment..."
@@ -158,21 +145,44 @@ if yes?("Should I push your current branch (#{branch}) to heroku?")
158 145
 
159 146
   puts "Running database migrations..."
160 147
   puts capture("heroku run rake db:migrate")
148
+end
161 149
 
150
+if first_time
151
+  puts "Restarting..."
152
+  puts capture("heroku restart")
153
+  puts "Done!"
162 154
   puts
163 155
   puts
164 156
   puts "I can make an admin user on your new Huginn instance and setup some example Agents."
165 157
   if yes?("Should I create a new admin user and some example Agents?")
166
-    seed_email = nag "Okay, what is your email address?"
167
-    seed_username = nag "And what username would you like to login as?"
168
-    seed_password = nag "Finally, what password would you like to use?", noecho: true
169
-    puts "\nJust a moment..."
170
-
171
-    capture("heroku run rake db:seed SEED_EMAIL=#{seed_email} SEED_USERNAME=#{seed_username} SEED_PASSWORD=#{seed_password}")
158
+    done = false
159
+    while !done
160
+      seed_email = nag "Okay, what is your email address?"
161
+      seed_username = nag "And what username would you like to login as?"
162
+      seed_password = nag "Finally, what password would you like to use?", noecho: true
163
+      puts "\nJust a moment..."
164
+
165
+      result = capture("heroku run rake db:seed SEED_EMAIL=#{seed_email} SEED_USERNAME=#{seed_username} SEED_PASSWORD=#{seed_password}")
166
+      if result =~ /Validation failed/
167
+        puts "ERROR:"
168
+        puts
169
+        puts result
170
+        puts
171
+      else
172
+        done = true
173
+      end
174
+    end
172 175
     puts
173 176
     puts
174 177
     puts "Okay, you should be all set!  Visit https://#{app_name}.herokuapp.com and login as '#{seed_username}' with your password."
178
+    puts
179
+    puts "If you'd like to make more users, you can visit https://#{app_name}.herokuapp.com/users/sign_up and use the invitation code:"
180
+  else
181
+    puts
182
+    puts "Visit https://#{app_name}.herokuapp.com/users/sign_up and use the invitation code shown below:"
175 183
   end
184
+  puts
185
+  puts "\t#{$config['INVITATION_CODE']}"
176 186
 end
177 187
 
178 188
 puts

+ 4 - 0
bin/threaded.rb

@@ -1,6 +1,9 @@
1 1
 require 'thread'
2 2
 require 'huginn_scheduler'
3 3
 
4
+STDOUT.sync = true
5
+STDERR.sync = true
6
+
4 7
 def stop
5 8
   puts 'Exiting...'
6 9
   @scheduler.stop
@@ -14,6 +17,7 @@ def safely(&block)
14 17
   rescue StandardError => e
15 18
     STDERR.puts "\nException #{e.message}:\n#{e.backtrace.join("\n")}\n\n"
16 19
     STDERR.puts "Terminating myself ..."
20
+    STDERR.flush
17 21
     stop
18 22
   end
19 23
 end

+ 1 - 1
config/environments/production.rb

@@ -61,7 +61,7 @@ Huginn::Application.configure do
61 61
   end
62 62
 
63 63
   # Precompile additional assets (application.js.coffee.erb, application.css, and all non-JS/CSS are already added)
64
-  config.assets.precompile += %w( diagram.js graphing.js user_credentials.js )
64
+  config.assets.precompile += %w( diagram.js graphing.js map_marker.js user_credentials.js )
65 65
 
66 66
   # Ignore bad email addresses and do not raise email delivery errors.
67 67
   # Set this to true and configure the email server for immediate delivery to raise delivery errors.

+ 1 - 1
config/initializers/aws.rb

@@ -1,4 +1,4 @@
1
-unless Rails.env.test?
1
+if defined?(RTurk) && !Rails.env.test?
2 2
   RTurk::logger.level = Logger::DEBUG
3 3
   RTurk.setup(ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_ACCESS_KEY'], :sandbox => ENV['AWS_SANDBOX'] == "true")
4 4
 end

+ 41 - 4
config/initializers/omniauth.rb

@@ -1,6 +1,43 @@
1
+OMNIAUTH_PROVIDERS = {}.tap { |providers|
2
+  if defined?(OmniAuth::Strategies::Twitter) &&
3
+     (key = ENV["TWITTER_OAUTH_KEY"]).present? &&
4
+     (secret = ENV["TWITTER_OAUTH_SECRET"]).present?
5
+    providers['twitter'] = {
6
+      omniauth_params: [key, secret, authorize_params: {force_login: 'true', use_authorize: 'true'}]
7
+    }
8
+  end
9
+
10
+  if defined?(OmniAuth::Strategies::ThirtySevenSignals) &&
11
+     (key = ENV["THIRTY_SEVEN_SIGNALS_OAUTH_KEY"]).present? &&
12
+     (secret = ENV["THIRTY_SEVEN_SIGNALS_OAUTH_SECRET"]).present?
13
+    providers['37signals'] = {
14
+      omniauth_params: [key, secret]
15
+    }
16
+  end
17
+
18
+  if defined?(OmniAuth::Strategies::GitHub) &&
19
+     (key = ENV["GITHUB_OAUTH_KEY"]).present? &&
20
+     (secret = ENV["GITHUB_OAUTH_SECRET"]).present?
21
+    providers['github'] = {
22
+      omniauth_params: [key, secret]
23
+    }
24
+  end
25
+
26
+  if defined?(OmniAuth::Strategies::Tumblr) &&
27
+     (key = ENV["TUMBLR_OAUTH_KEY"]).present? &&
28
+     (secret = ENV["TUMBLR_OAUTH_SECRET"]).present?
29
+    providers['tumblr'] = {
30
+      omniauth_params: [key, secret]
31
+    }
32
+  end
33
+}
34
+
35
+def has_oauth_configuration_for?(provider)
36
+  OMNIAUTH_PROVIDERS.key?(provider.to_s)
37
+end
38
+
1 39
 Rails.application.config.middleware.use OmniAuth::Builder do
2
-  provider :twitter, ENV['TWITTER_OAUTH_KEY'], ENV['TWITTER_OAUTH_SECRET'], authorize_params: {force_login: 'true', use_authorize: 'true'}
3
-  provider '37signals', ENV['THIRTY_SEVEN_SIGNALS_OAUTH_KEY'], ENV['THIRTY_SEVEN_SIGNALS_OAUTH_SECRET']
4
-  provider :github, ENV['GITHUB_OAUTH_KEY'], ENV['GITHUB_OAUTH_SECRET']
5
-  provider :tumblr, ENV['TUMBLR_OAUTH_KEY'], ENV['TUMBLR_OAUTH_SECRET']
40
+  OMNIAUTH_PROVIDERS.each { |name, config|
41
+    provider name, *config[:omniauth_params]
42
+  }
6 43
 end

+ 30 - 0
docker/Dockerfile

@@ -0,0 +1,30 @@
1
+FROM ubuntu:14.04
2
+MAINTAINER Andrew Cantino
3
+
4
+ENV DEBIAN_FRONTEND noninteractive
5
+RUN apt-get update && \
6
+    apt-get install -y software-properties-common && \
7
+    add-apt-repository -y ppa:git-core/ppa && \
8
+    add-apt-repository -y ppa:brightbox/ruby-ng && \
9
+    apt-get update && \
10
+    apt-get install -y build-essential checkinstall postgresql-client \
11
+      git-core mysql-server redis-server python2.7 python-docutils \
12
+      libmysqlclient-dev libpq-dev zlib1g-dev libyaml-dev libssl-dev \
13
+      libgdbm-dev libreadline-dev libncurses5-dev libffi-dev \
14
+      libxml2-dev libxslt-dev libcurl4-openssl-dev libicu-dev \
15
+      graphviz libgraphviz-dev \
16
+      ruby2.1 ruby2.1-dev supervisor && \
17
+    gem install --no-ri --no-rdoc bundler && \
18
+    rm -rf /var/lib/apt/lists/*
19
+
20
+ADD scripts/ /scripts
21
+RUN chmod 755 /scripts/setup /scripts/init
22
+
23
+RUN /scripts/setup
24
+
25
+VOLUME /var/lib/mysql
26
+
27
+EXPOSE 5000
28
+
29
+CMD ["/scripts/init"]
30
+

Dockerfile → docker/Dockerfile.rbenv


+ 2 - 0
docker/Makefile

@@ -0,0 +1,2 @@
1
+build:
2
+	docker build -t cantino/huginn .

+ 137 - 0
docker/README.md

@@ -0,0 +1,137 @@
1
+Huginn for docker with multiple container linkage
2
+=================================================
3
+
4
+This image runs a linkable [Huginn](https://github.com/cantino/huginn) instance.
5
+
6
+There is an automated build repository on docker hub for [cantino/huginn](https://registry.hub.docker.com/builds/github/cantino/huginn/).
7
+
8
+This was patterned after [sameersbn/gitlab](https://registry.hub.docker.com/u/sameersbn/gitlab) by [ianblenke/huginn](http://github.com/ianblenke/huginn), and imported here for official generation of a docker hub auto-build image.
9
+
10
+The scripts/init script generates a .env file containing the variables as passed as per normal Huginn documentation.
11
+The same environment variables that would be used for Heroku PaaS deployment are used by this script.
12
+
13
+The scripts/init script is aware of mysql and postgres linked containers through the environment variables:
14
+
15
+    MYSQL_PORT_3306_TCP_ADDR
16
+    MYSQL_PORT_3306_TCP_PORT
17
+
18
+and
19
+
20
+    POSTGRESQL_PORT_5432_TCP_ADDR
21
+    POSTGRESQL_PORT_5432_TCP_PORT
22
+
23
+Its recommended to use an image that allows you to create a database via environmental variables at docker run, like `paintedfox / postgresql` or `centurylink / mysql`, so the db is populated when this script runs.
24
+
25
+If you do not link a database container, a built-in mysql database will be started.
26
+There is an exported docker volume of /var/lib/mysql to allow persistence of that mysql database.
27
+
28
+Additionally, the database variables may be overridden from the above as per the standard Huginn documentation:
29
+
30
+    HUGINN_DATABASE_ADAPTER #(must be either 'postgres' or 'mysql2')
31
+    HUGINN_DATABASE_HOST
32
+    HUGINN_DATABASE_PORT
33
+
34
+This script will run database migrations (rake db:migrate) which should be idempotent.
35
+
36
+It will also seed the database (rake db:seed) unless this is defined:
37
+
38
+    DO_NOT_SEED
39
+
40
+This same seeding initially defines the "admin" user with a default password of "password" as per the standard Huginn documentation.
41
+
42
+If you do not wish to have the default 6 agents, you will want to set the above environment variable after your initially deploy, otherwise they will be added automatically the next time a container pointing at the database is spun up.
43
+
44
+The CMD launches Huginn via the scripts/init script. This may become the ENTRYPOINT later.  It does take under a minute for Huginn to come up.  Use environmental variables that match your DB's creds to ensure it works.
45
+
46
+## Usage
47
+
48
+Simple stand-alone usage:
49
+
50
+    docker run -it -p 5000:5000 cantino/huginn
51
+
52
+To link to another mysql container, for example:
53
+
54
+    docker run --rm --name newcentury_mysql -p 3306 \
55
+        -e HUGINN_MYSQL_DATABASE=huginn \
56
+        -e HUGINN_MYSQL_USER=huginn \
57
+        -e HUGINN_MYSQL_PASSWORD=somethingsecret \
58
+        -e HUGINN_MYSQL_ROOT_PASSWORD=somethingevenmoresecret \
59
+        cantino/huginn
60
+    docker run --rm --name huginn --link newcentury_mysql:MYSQL -p 5000:5000 \
61
+        -e HUGINN_DATABASE_NAME=huginn \
62
+        -e HUGINN_DATABASE_USER=huginn \
63
+        -e HUGINN_DATABASE_PASSWORD=somethingsecret \
64
+        cantino/huginn
65
+
66
+To link to another container named 'postgres':
67
+
68
+    docker run --rm --name huginn --link POSTGRES:mysql -p 5000:5000 -e "DATABASE_USER=huginn" -e "DATABASE_PASSWORD=pass@word" cantino/huginn
69
+
70
+## Environment Variables
71
+
72
+Other Huginn 12factored environment variables of note, as generated and put into the .env file as per Huginn documentation,
73
+with an additional `HUGINN_` prefix to the variable.
74
+
75
+These are:
76
+
77
+    HUGINN_APP_SECRET_TOKEN
78
+    HUGINN_DOMAIN
79
+    HUGINN_ASSET_HOST
80
+    HUGINN_DATABASE_ADAPTER
81
+    HUGINN_DATABASE_ENCODING
82
+    HUGINN_DATABASE_RECONNECT
83
+    HUGINN_DATABASE_NAME
84
+    HUGINN_DATABASE_POOL
85
+    HUGINN_DATABASE_USERNAME
86
+    HUGINN_DATABASE_PASSWORD
87
+    HUGINN_DATABASE_HOST
88
+    HUGINN_DATABASE_PORT
89
+    HUGINN_DATABASE_SOCKET
90
+    HUGINN_RAILS_ENV
91
+    HUGINN_FORCE_SSL
92
+    HUGINN_INVITATION_CODE
93
+    HUGINN_SMTP_DOMAIM
94
+    HUGINN_SMTP_USER_NAME
95
+    HUGINN_SMTP_PASSWORD
96
+    HUGINN_SMTP_SERVER
97
+    HUGINN_SMTP_PORT
98
+    HUGINN_SMTP_AUTHENTICATION
99
+    HUGINN_SMTP_ENABLE_STARTTLS_AUTO
100
+    HUGINN_EMAIL_FROM_ADDRESS
101
+    HUGINN_AGENT_LOG_LENGTH
102
+    HUGINN_TWITTER_OAUTH_KEY
103
+    HUGINN_TWITTER_OAUTH_SECRET
104
+    HUGINN_THIRTY_SEVEN_SIGNALS_OAUTH_KEY
105
+    HUGINN_THIRTY_SEVEN_SIGNALS_OAUTH_SECRET
106
+    HUGINN_GITHUB_OAUTH_KEY
107
+    HUGINN_GITHUB_OAUTH_SECRET
108
+    HUGINN_AWS_ACCESS_KEY_ID
109
+    HUGINN_AWS_ACCESS_KEY
110
+    HUGINN_AWS_SANDBOX
111
+    HUGINN_FARADAY_HTTP_BACKEND
112
+    HUGINN_DEFAULT_HTTP_USER_AGENT
113
+    HUGINN_ALLOW_JSONPATH_EVAL
114
+    HUGINN_ENABLE_INSECURE_AGENTS
115
+    HUGGIN_ENABLE_SECOND_PRECISION_SCHEDULE
116
+    HUGINN_USE_GRAPHVIZ_DOT
117
+    HUGINN_TIMEZONE
118
+    HUGGIN_FAILED_JOBS_TO_KEEP
119
+
120
+
121
+The above environment variables will override the defaults. The defaults are read from the [.env.example](https://github.com/cantino/huginn/blob/master/.env.example) file.
122
+
123
+For variables in the .env.example that are commented out, the default is to not include that variable in the generated .env file.
124
+
125
+## Building on your own
126
+
127
+You don't need to do this on your own, because there is an [automated build](https://registry.hub.docker.com/u/cantino/huginn/) for this repository, but if you really want:
128
+
129
+    docker build --rm=true --tag={yourname}/huginn .
130
+
131
+## Source
132
+
133
+The source is [available on GitHub](https://github.com/cantino/huginn/).
134
+
135
+Please feel free to submit pull requests and/or fork at your leisure.
136
+
137
+

+ 111 - 0
docker/scripts/init

@@ -0,0 +1,111 @@
1
+#!/bin/bash
2
+set -e
3
+
4
+cd /app
5
+
6
+# Default to the environment variable values set in .env.example
7
+source /app/.env.example
8
+
9
+# is a mysql or postgresql database linked?
10
+# requires that the mysql or postgresql containers have exposed
11
+# port 3306 and 5432 respectively.
12
+if [ -n "${MYSQL_PORT_3306_TCP_ADDR}" ]; then
13
+  HUGINN_DATABASE_ADAPTER=${HUGINN_DATABASE_ADAPTER:-mysql2}
14
+  HUGINN_DATABASE_HOST=${HUGINN_DATABASE_HOST:-${MYSQL_PORT_3306_TCP_ADDR}}
15
+  HUGINN_DATABASE_PORT=${HUGINN_DATABASE_PORT:-${MYSQL_PORT_3306_TCP_PORT}}
16
+elif [ -n "${POSTGRESQL_PORT_5432_TCP_ADDR}" ]; then
17
+  HUGINN_DATABASE_ADAPTER=${HUGINN_DATABASE_ADAPTER:-postgres}
18
+  HUGINN_DATABASE_HOST=${HUGINN_DATABASE_HOST:-${POSTGRESQL_PORT_5432_TCP_ADDR}}
19
+  HUGINN_DATABASE_PORT=${HUGINN_DATABASE_PORT:-${POSTGRESQL_PORT_5432_TCP_PORT}}
20
+fi
21
+
22
+grep = /app/.env.example | sed -e 's/^#[^ ]//' | grep -v -e '^#' | cut -d= -f1 | \
23
+  while read var ; do
24
+    eval "echo \"$var=\\\"\${HUGINN_$var:-\$$var}\\\"\""
25
+  done | grep -v -e ^= > /app/.env
26
+
27
+chmod ugo+r /app/.env
28
+source /app/.env
29
+
30
+DATABASE_HOST=${HUGINN_DATABASE_HOST:-${DATABASE_HOST:-localhost}}
31
+DATABASE_ENCODING=${HUGINN_DATABASE_ENCODING:-${DATABASE_ENCODING}}
32
+USE_GRAPHVIZ_DOT=${HUGINN_USE_GRAPHVIZ_DOT:-${USE_GRAPHVIZ_DOT}}
33
+
34
+# use default port number if it is still not set
35
+case "${DATABASE_ADAPTER}" in
36
+  mysql2) DATABASE_PORT=${DATABASE_PORT:-3306} ;;
37
+  postgres) DATABASE_PORT=${DATABASE_PORT:-5432} ;;
38
+  *) echo "Unsupported database adapter. Available adapters are mysql2, and postgres." && exit 1 ;;
39
+esac
40
+
41
+# start supervisord
42
+/usr/bin/supervisord -c /etc/supervisor/supervisord.conf
43
+
44
+# start mysql server if ${DATABASE_HOST} is localhost
45
+if [ "${DATABASE_HOST}" == "localhost" ]; then
46
+  if [ "${DATABASE_ADAPTER}" == "postgres" ]; then
47
+    echo "DATABASE_ADAPTER 'postgres' is not supported internally. Please provide DATABASE_HOST."
48
+    exit 1
49
+  fi
50
+
51
+  # configure supervisord to start mysql (manual)
52
+  cat > /etc/supervisor/conf.d/mysqld.conf <<EOF
53
+[program:mysqld]
54
+priority=20
55
+directory=/tmp
56
+command=/usr/bin/mysqld_safe
57
+user=root
58
+autostart=false
59
+autorestart=true
60
+stdout_logfile=/var/log/supervisor/%(program_name)s.log
61
+stderr_logfile=/var/log/supervisor/%(program_name)s.log
62
+EOF
63
+  supervisorctl reload
64
+
65
+  # fix permissions and ownership of /var/lib/mysql
66
+  chown -R mysql:mysql /var/lib/mysql
67
+  chmod 700 /var/lib/mysql
68
+
69
+  # initialize MySQL data directory
70
+  if [ ! -d /var/lib/mysql/mysql ]; then
71
+    mysql_install_db --user=mysql
72
+  fi
73
+
74
+  echo "Starting mysql server..."
75
+  supervisorctl start mysqld >/dev/null
76
+
77
+  # wait for mysql server to start (max 120 seconds)
78
+  timeout=120
79
+  while ! mysqladmin -uroot ${DATABASE_PASSWORD:+-p$DATABASE_PASSWORD} status >/dev/null 2>&1
80
+  do
81
+    timeout=$(expr $timeout - 1)
82
+    if [ $timeout -eq 0 ]; then
83
+      echo "Failed to start mysql server"
84
+      exit 1
85
+    fi
86
+    sleep 1
87
+  done
88
+
89
+  if ! echo "USE ${DATABASE_NAME}" | mysql -uroot ${DATABASE_PASSWORD:+-p$DATABASE_PASSWORD} >/dev/null 2>&1; then
90
+    DB_INIT="yes"
91
+    echo "CREATE DATABASE IF NOT EXISTS \`${DATABASE_NAME}\` DEFAULT CHARACTER SET \`utf8\` COLLATE \`utf8_unicode_ci\`;" | mysql -uroot ${DATABASE_PASSWORD:+-p$DATABASE_PASSWORD}
92
+    echo "GRANT SELECT, LOCK TABLES, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER ON \`${DATABASE_NAME}\`.* TO 'root'@'localhost';" | mysql -uroot ${DATABASE_PASSWORD:+-p$DATABASE_PASSWORD}
93
+  fi
94
+fi
95
+
96
+# Assuming we have a created database, run the migrations and seed it idempotently.
97
+[ -z "${DO_NOT_MIGRATE}" ] && sudo -u huginn -EH bundle exec rake db:migrate
98
+[ -z "${DO_NOT_SEED}" ] && sudo -u huginn -EH bundle exec rake db:seed
99
+
100
+[ -n "$INTENTIONALLY_SLEEP" ] && sleep $INTENTIONALLY_SLEEP
101
+
102
+# Fixup the Procfile and prepare the PORT
103
+[ -z "${DO_NOT_RUN_JOBS}" ] && perl -pi -e 's/^jobs:/#jobs:/' /app/Procfile
104
+perl -pi -e 's/rails server$/rails server -p \$PORT/' /app/Procfile
105
+export PORT
106
+
107
+# Start huginn
108
+sudo -u huginn -EH bundle exec foreman start
109
+
110
+# As the ENTRYPOINT script, when this exits the docker container will Exit.
111
+exit 0

+ 39 - 0
docker/scripts/setup

@@ -0,0 +1,39 @@
1
+#!/bin/bash
2
+set -e
3
+
4
+# Initialize variables used by Huginn at installation time
5
+
6
+# Huginn is 12factor aware, embrace that fact for use inside of docker
7
+ON_HEROKU=${ON_HEROKU:-true}
8
+
9
+# Shallow clone the huginn project repo
10
+git clone --depth 1 https://github.com/cantino/huginn /app
11
+
12
+cd /app
13
+
14
+# add a huginn group and user
15
+adduser --group huginn
16
+adduser --disabled-login --ingroup huginn --gecos 'Huginn' --no-create-home --home /app huginn
17
+adduser huginn sudo
18
+passwd -d huginn
19
+
20
+# Change the ownership to huginn
21
+chown -R huginn:huginn /app
22
+
23
+# create required tmp and log directories
24
+sudo -u huginn -H mkdir -p tmp/pids tmp/cache tmp/sockets log
25
+chmod -R u+rwX log tmp
26
+
27
+# install gems required by Huginn, use local cache if available
28
+if [ -d "/scripts/cache" ]; then
29
+  mv /scripts/cache vendor/
30
+  chown -R huginn:huginn vendor/cache
31
+fi
32
+sudo -u huginn -H bundle install --deployment --without development test
33
+
34
+# silence setlocale message (THANKS DEBIAN!)
35
+cat > /etc/default/locale <<EOF
36
+LC_ALL=en_US.UTF-8
37
+LANG=en_US.UTF-8
38
+EOF
39
+

+ 1 - 1
lib/huginn_scheduler.rb

@@ -40,7 +40,7 @@ class Rufus::Scheduler
40 40
   def schedule_scheduler_agent(agent)
41 41
     job = scheduler_agent_job(agent)
42 42
 
43
-    if agent.disabled?
43
+    if agent.unavailable?
44 44
       if job
45 45
         puts "Unscheduling SchedulerAgent##{agent.id} (disabled)"
46 46
         job.unschedule

+ 110 - 0
lib/location.rb

@@ -0,0 +1,110 @@
1
+require 'liquid'
2
+
3
+Location = Struct.new(:lat, :lng, :radius, :speed, :course)
4
+
5
+class Location
6
+  include LiquidDroppable
7
+
8
+  protected :[]=
9
+
10
+  def initialize(data = {})
11
+    super()
12
+
13
+    case data
14
+    when Array
15
+      raise ArgumentError, 'unsupported location data' unless data.size == 2
16
+      self.lat, self.lng = data
17
+    when Hash, Location
18
+      data.each { |key, value|
19
+        case key.to_sym
20
+        when :lat, :latitude
21
+          self.lat = value
22
+        when :lng, :longitude
23
+          self.lng = value
24
+        when :radius
25
+          self.radius = value
26
+        when :speed
27
+          self.speed = value
28
+        when :course
29
+          self.course = value
30
+        end
31
+      }
32
+    else
33
+      raise ArgumentError, 'unsupported location data'
34
+    end
35
+
36
+    yield self if block_given?
37
+  end
38
+
39
+  def lat=(value)
40
+    self[:lat] = floatify(value) { |f|
41
+      if f.abs <= 90
42
+        f
43
+      else
44
+        raise ArgumentError, 'out of bounds'
45
+      end
46
+    }
47
+  end
48
+
49
+  alias latitude  lat
50
+  alias latitude= lat=
51
+
52
+  def lng=(value)
53
+    self[:lng] = floatify(value) { |f|
54
+      if f.abs <= 180
55
+        f
56
+      else
57
+        raise ArgumentError, 'out of bounds'
58
+      end
59
+    }
60
+  end
61
+
62
+  alias longitude  lng
63
+  alias longitude= lng=
64
+
65
+  def radius=(value)
66
+    self[:radius] = floatify(value) { |f| f if f >= 0 }
67
+  end
68
+
69
+  def speed=(value)
70
+    self[:speed] = floatify(value) { |f| f if f >= 0 }
71
+  end
72
+
73
+  def course=(value)
74
+    self[:course] = floatify(value) { |f| f if (0..360).cover?(f) }
75
+  end
76
+
77
+  def present?
78
+    lat && lng
79
+  end
80
+
81
+  def empty?
82
+    !present?
83
+  end
84
+
85
+  private
86
+
87
+  def floatify(value)
88
+    case value
89
+    when nil, ''
90
+      return nil
91
+    else
92
+      float = Float(value)
93
+      if block_given?
94
+        yield(float)
95
+      else
96
+        float
97
+      end
98
+    end
99
+  end
100
+end
101
+
102
+class LocationDrop
103
+  KEYS = Location.members.map(&:to_s).concat(%w[latitude longitude])
104
+
105
+  def before_method(key)
106
+    if KEYS.include?(key)
107
+      @object.__send__(key)
108
+    end
109
+  end
110
+end

+ 8 - 1
lib/twitter_stream.rb

@@ -1,6 +1,5 @@
1 1
 require 'cgi'
2 2
 require 'json'
3
-require 'twitter/json_stream'
4 3
 require 'em-http-request'
5 4
 require 'pp'
6 5
 
@@ -88,6 +87,14 @@ class TwitterStream
88 87
   SEPARATOR = /[^\w_\-]+/
89 88
 
90 89
   def run
90
+    if Agents::TwitterStreamAgent.dependencies_missing?
91
+      STDERR.puts Agents::TwitterStreamAgent.twitter_dependencies_missing
92
+      STDERR.flush
93
+      return
94
+    end
95
+
96
+    require 'twitter/json_stream'
97
+
91 98
     while @running
92 99
       begin
93 100
         agents = Agents::TwitterStreamAgent.active.all

+ 68 - 0
spec/lib/location_spec.rb

@@ -0,0 +1,68 @@
1
+require 'spec_helper'
2
+
3
+describe Location do
4
+  let(:location) {
5
+    Location.new(
6
+      lat: BigDecimal.new('2.0'),
7
+      lng: BigDecimal.new('3.0'),
8
+      radius: 300,
9
+      speed: 2,
10
+      course: 30)
11
+  }
12
+
13
+  it "converts values to Float" do
14
+    expect(location.lat).to be_a Float
15
+    expect(location.lat).to eq 2.0
16
+    expect(location.lng).to be_a Float
17
+    expect(location.lng).to eq 3.0
18
+    expect(location.radius).to be_a Float
19
+    expect(location.radius).to eq 300.0
20
+    expect(location.speed).to be_a Float
21
+    expect(location.speed).to eq 2.0
22
+    expect(location.course).to be_a Float
23
+    expect(location.course).to eq 30.0
24
+  end
25
+
26
+  it "provides hash-style access to its properties with both symbol and string keys" do
27
+    expect(location[:lat]).to be_a Float
28
+    expect(location[:lat]).to eq 2.0
29
+    expect(location['lat']).to be_a Float
30
+    expect(location['lat']).to eq 2.0
31
+  end
32
+
33
+  it "does not allow hash-style assignment" do
34
+    expect {
35
+      location[:lat] = 2.0
36
+    }.to raise_error
37
+  end
38
+
39
+  it "ignores invalid values" do
40
+    location2 = Location.new(
41
+      lat: 2,
42
+      lng: 3,
43
+      radius: -1,
44
+      speed: -1,
45
+      course: -1)
46
+    expect(location2.radius).to be_nil
47
+    expect(location2.speed).to be_nil
48
+    expect(location2.course).to be_nil
49
+  end
50
+
51
+  it "considers a location empty if either latitude or longitude is missing" do
52
+    expect(Location.new.empty?).to be_truthy
53
+    expect(Location.new(lat: 2, radius: 1).present?).to be_falsy
54
+    expect(Location.new(lng: 3, radius: 1).present?).to be_falsy
55
+  end
56
+
57
+  it "is droppable" do
58
+    {
59
+      '{{location.lat}}' => '2.0',
60
+      '{{location.latitude}}' => '2.0',
61
+      '{{location.lng}}' => '3.0',
62
+      '{{location.longitude}}' => '3.0',
63
+    }.each { |template, result|
64
+      expect(Liquid::Template.parse(template).render('location' => location.to_liquid)).to eq(result),
65
+        "expected #{template.inspect} to expand to #{result.inspect}"
66
+    }
67
+  end
68
+end

+ 51 - 0
spec/models/event_spec.rb

@@ -1,6 +1,50 @@
1 1
 require 'spec_helper'
2 2
 
3 3
 describe Event do
4
+  describe ".with_location" do
5
+    it "selects events with location" do
6
+      event = events(:bob_website_agent_event)
7
+      event.lat = 2
8
+      event.lng = 3
9
+      event.save!
10
+      Event.with_location.pluck(:id).should == [event.id]
11
+
12
+      event.lat = nil
13
+      event.save!
14
+      Event.with_location.should be_empty
15
+    end
16
+  end
17
+
18
+  describe "#location" do
19
+    it "returns a default hash when an event does not have a location" do
20
+      event = events(:bob_website_agent_event)
21
+      event.location.should == Location.new(
22
+        lat: nil,
23
+        lng: nil,
24
+        radius: 0.0,
25
+        speed: nil,
26
+        course: nil)
27
+    end
28
+
29
+    it "returns a hash containing location information" do
30
+      event = events(:bob_website_agent_event)
31
+      event.lat = 2
32
+      event.lng = 3
33
+      event.payload = {
34
+        radius: 300,
35
+        speed: 0.5,
36
+        course: 90.0,
37
+      }
38
+      event.save!
39
+      event.location.should == Location.new(
40
+        lat: 2.0,
41
+        lng: 3.0,
42
+        radius: 0.0,
43
+        speed: 0.5,
44
+        course: 90.0)
45
+    end
46
+  end
47
+
4 48
   describe "#reemit" do
5 49
     it "creates a new event identical to itself" do
6 50
       events(:bob_website_agent_event).lat = 2
@@ -130,6 +174,8 @@ describe EventDrop do
130 174
       'title' => 'some title',
131 175
       'url' => 'http://some.site.example.org/',
132 176
     }
177
+    @event.lat = 2
178
+    @event.lng = 3
133 179
     @event.save!
134 180
   end
135 181
 
@@ -166,4 +212,9 @@ describe EventDrop do
166 212
     t = '{{created_at | date:"%FT%T%z" }}'
167 213
     interpolate(t, @event).should eq(@event.created_at.strftime("%FT%T%z"))
168 214
   end
215
+
216
+  it 'should have _location_' do
217
+    t = '{{_location_.lat}},{{_location_.lng}}'
218
+    interpolate(t, @event).should eq("2.0,3.0")
219
+  end
169 220
 end

+ 2 - 1
vendor/assets/javascripts/jquery.json-editor.js

@@ -1,3 +1,4 @@
1
+
1 2
 /*
2 3
   Copyright (c) 2014, Andrew Cantino
3 4
   Copyright (c) 2009, Andrew Cantino & Kyle Maxwell
@@ -528,7 +529,7 @@
528 529
           }
529 530
           innerbq.append($('<span class="colon">: </span>'));
530 531
           newElem = this.build(jsonvalue, innerbq, json, jsonkey, root);
531
-          if (newElem && newElem.text() === "??") {
532
+          if (!elem && newElem && newElem.text() === "??") {
532 533
             elem = newElem;
533 534
           }
534 535
           bq.append(innerbq);